mirror of
https://github.com/openai/codex.git
synced 2026-03-14 10:43:49 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70eddad6b0 | ||
|
|
e389091042 | ||
|
|
8ca358a13c | ||
|
|
ae0a6510e1 |
@@ -506,6 +506,9 @@
|
||||
},
|
||||
"DynamicToolSpec": {
|
||||
"properties": {
|
||||
"deferLoading": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -7050,6 +7050,9 @@
|
||||
},
|
||||
"DynamicToolSpec": {
|
||||
"properties": {
|
||||
"deferLoading": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9292,8 +9295,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3651,6 +3651,9 @@
|
||||
},
|
||||
"DynamicToolSpec": {
|
||||
"properties": {
|
||||
"deferLoading": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6037,8 +6040,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -51,8 +51,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -125,8 +125,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
},
|
||||
"DynamicToolSpec": {
|
||||
"properties": {
|
||||
"deferLoading": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JsonValue } from "../serde_json/JsonValue";
|
||||
|
||||
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, };
|
||||
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };
|
||||
|
||||
@@ -3,4 +3,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, defaultPrompt: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
|
||||
export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null,
|
||||
/**
|
||||
* Starter prompts for the plugin. Capped at 3 entries with a maximum of
|
||||
* 128 characters per entry.
|
||||
*/
|
||||
defaultPrompt: Array<string> | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
|
||||
|
||||
@@ -535,13 +535,48 @@ pub struct ToolsV2 {
|
||||
pub view_image: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct DynamicToolSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: JsonValue,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub defer_loading: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DynamicToolSpecDe {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: JsonValue,
|
||||
defer_loading: Option<bool>,
|
||||
expose_to_context: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let DynamicToolSpecDe {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading,
|
||||
expose_to_context,
|
||||
} = DynamicToolSpecDe::deserialize(deserializer)?;
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading: defer_loading
|
||||
.unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
|
||||
@@ -3355,7 +3390,9 @@ pub struct PluginInterface {
|
||||
pub website_url: Option<String>,
|
||||
pub privacy_policy_url: Option<String>,
|
||||
pub terms_of_service_url: Option<String>,
|
||||
pub default_prompt: Option<String>,
|
||||
/// Starter prompts for the plugin. Capped at 3 entries with a maximum of
|
||||
/// 128 characters per entry.
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
@@ -7653,6 +7690,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_deserializes_defer_loading() {
|
||||
let value = json!({
|
||||
"name": "lookup_ticket",
|
||||
"description": "Fetch a ticket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"deferLoading": true,
|
||||
});
|
||||
|
||||
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
DynamicToolSpec {
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
defer_loading: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() {
|
||||
let value = json!({
|
||||
"name": "lookup_ticket",
|
||||
"description": "Fetch a ticket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"exposeToContext": false,
|
||||
});
|
||||
|
||||
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert!(actual.defer_loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_start_params_preserve_explicit_null_service_tier() {
|
||||
let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null }))
|
||||
|
||||
@@ -205,6 +205,7 @@ Start a fresh thread when you need a new Codex conversation.
|
||||
{
|
||||
"name": "lookup_ticket",
|
||||
"description": "Fetch a ticket by id",
|
||||
"deferLoading": true,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -991,6 +992,8 @@ If the session approval policy uses `Granular` with `request_permissions: false`
|
||||
|
||||
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
|
||||
|
||||
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `js_repl`, while excluding it from the model-facing tool list sent on ordinary turns.
|
||||
|
||||
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:
|
||||
|
||||
```json
|
||||
|
||||
@@ -2007,6 +2007,7 @@ impl CodexMessageProcessor {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.input_schema,
|
||||
defer_loading: tool.defer_loading,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -8185,6 +8186,7 @@ mod tests {
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({"type": "null"}),
|
||||
defer_loading: false,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("invalid schema");
|
||||
assert!(err.contains("my_tool"), "unexpected error: {err}");
|
||||
@@ -8197,6 +8199,7 @@ mod tests {
|
||||
description: "test".to_string(),
|
||||
// Missing `type` is common; core sanitizes these to a supported schema.
|
||||
input_schema: json!({"properties": {}}),
|
||||
defer_loading: false,
|
||||
}];
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
|
||||
name: "demo_tool".to_string(),
|
||||
description: "Demo dynamic tool".to_string(),
|
||||
input_schema: input_schema.clone(),
|
||||
defer_loading: false,
|
||||
};
|
||||
|
||||
// Thread start injects dynamic tools into the thread's tool registry.
|
||||
@@ -118,6 +119,78 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Result<()> {
|
||||
let responses = vec![create_final_assistant_message_sse_response("Done")?];
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
name: "hidden_tool".to_string(),
|
||||
description: "Hidden dynamic tool".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
};
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
dynamic_tools: Some(vec![dynamic_tool.clone()]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let bodies = responses_bodies(&server).await?;
|
||||
assert!(
|
||||
bodies
|
||||
.iter()
|
||||
.all(|body| find_tool(body, &dynamic_tool.name).is_none()),
|
||||
"hidden dynamic tool should not be sent to the model"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exercises the full dynamic tool call path (server request, client response, model output).
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
|
||||
@@ -154,6 +227,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
"required": ["city"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
};
|
||||
|
||||
let thread_req = mcp
|
||||
@@ -322,6 +396,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
"required": ["city"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
};
|
||||
|
||||
let thread_req = mcp
|
||||
|
||||
@@ -407,7 +407,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
"websiteURL": "https://openai.com/",
|
||||
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
||||
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
||||
"defaultPrompt": "Starter prompt for trying a plugin",
|
||||
"defaultPrompt": [
|
||||
"Starter prompt for trying a plugin",
|
||||
"Find my next action"
|
||||
],
|
||||
"brandColor": "#3B82F6",
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
@@ -466,6 +469,13 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
interface.terms_of_service_url.as_deref(),
|
||||
Some("https://openai.com/policies/row-terms-of-use/")
|
||||
);
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec![
|
||||
"Starter prompt for trying a plugin".to_string(),
|
||||
"Find my next action".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
interface.composer_icon,
|
||||
Some(AbsolutePathBuf::try_from(
|
||||
@@ -488,6 +498,72 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r##"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"defaultPrompt": "Starter prompt for trying a plugin"
|
||||
}
|
||||
}"##,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
let plugin = response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.flat_map(|marketplace| marketplace.plugins.iter())
|
||||
.find(|plugin| plugin.name == "demo-plugin")
|
||||
.expect("expected demo-plugin entry");
|
||||
assert_eq!(
|
||||
plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec!["Starter prompt for trying a plugin".to_string()])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -58,7 +58,10 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
|
||||
"websiteURL": "https://openai.com/",
|
||||
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
||||
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
||||
"defaultPrompt": "Starter prompt for trying a plugin",
|
||||
"defaultPrompt": [
|
||||
"Draft the reply",
|
||||
"Find my next action"
|
||||
],
|
||||
"brandColor": "#3B82F6",
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
@@ -162,6 +165,18 @@ enabled = true
|
||||
.and_then(|interface| interface.category.as_deref()),
|
||||
Some("Design")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec![
|
||||
"Draft the reply".to_string(),
|
||||
"Find my next action".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(response.plugin.skills.len(), 1);
|
||||
assert_eq!(
|
||||
response.plugin.skills[0].name,
|
||||
@@ -183,6 +198,70 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r##"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"defaultPrompt": "Starter prompt for trying a plugin"
|
||||
}
|
||||
}"##,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?,
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec!["Starter prompt for trying a plugin".to_string()])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -2223,7 +2223,7 @@
|
||||
"$ref": "#/definitions/ModelProviderInfo"
|
||||
},
|
||||
"default": {},
|
||||
"description": "User-defined provider entries that extend/override the built-in list.",
|
||||
"description": "User-defined provider entries that extend the built-in list. Built-in IDs cannot be overridden.",
|
||||
"type": "object"
|
||||
},
|
||||
"model_reasoning_effort": {
|
||||
|
||||
@@ -6219,9 +6219,25 @@ fn build_prompt(
|
||||
turn_context: &TurnContext,
|
||||
base_instructions: BaseInstructions,
|
||||
) -> Prompt {
|
||||
let deferred_dynamic_tools = turn_context
|
||||
.dynamic_tools
|
||||
.iter()
|
||||
.filter(|tool| tool.defer_loading)
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let tools = if deferred_dynamic_tools.is_empty() {
|
||||
router.model_visible_specs()
|
||||
} else {
|
||||
router
|
||||
.model_visible_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| !deferred_dynamic_tools.contains(spec.name()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
Prompt {
|
||||
input,
|
||||
tools: router.model_visible_specs(),
|
||||
tools,
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
base_instructions,
|
||||
personality: turn_context.personality,
|
||||
|
||||
@@ -47,6 +47,7 @@ use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
|
||||
use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::OPENAI_PROVIDER_ID;
|
||||
use crate::model_provider_info::built_in_model_providers;
|
||||
use crate::path_utils::normalize_for_native_workdir;
|
||||
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
@@ -139,6 +140,11 @@ pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL";
|
||||
const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
|
||||
OPENAI_PROVIDER_ID,
|
||||
OLLAMA_OSS_PROVIDER_ID,
|
||||
LMSTUDIO_OSS_PROVIDER_ID,
|
||||
];
|
||||
|
||||
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
|
||||
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
|
||||
@@ -367,7 +373,7 @@ pub struct Config {
|
||||
/// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided).
|
||||
pub mcp_oauth_callback_url: Option<String>,
|
||||
|
||||
/// Combined provider map (defaults merged with user-defined overrides).
|
||||
/// Combined provider map (defaults plus user-defined providers).
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
@@ -1262,8 +1268,9 @@ pub struct ConfigToml {
|
||||
/// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided).
|
||||
pub mcp_oauth_callback_url: Option<String>,
|
||||
|
||||
/// User-defined provider entries that extend/override the built-in list.
|
||||
#[serde(default)]
|
||||
/// User-defined provider entries that extend the built-in list. Built-in
|
||||
/// IDs cannot be overridden.
|
||||
#[serde(default, deserialize_with = "deserialize_model_providers")]
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
@@ -1890,6 +1897,37 @@ pub struct ConfigOverrides {
|
||||
pub additional_writable_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn validate_reserved_model_provider_ids(
|
||||
model_providers: &HashMap<String, ModelProviderInfo>,
|
||||
) -> Result<(), String> {
|
||||
let mut conflicts = model_providers
|
||||
.keys()
|
||||
.filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str()))
|
||||
.map(|key| format!("`{key}`"))
|
||||
.collect::<Vec<_>>();
|
||||
conflicts.sort_unstable();
|
||||
if conflicts.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"model_providers contains reserved built-in provider IDs: {}. \
|
||||
Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).",
|
||||
conflicts.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_model_providers<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
|
||||
validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?;
|
||||
Ok(model_providers)
|
||||
}
|
||||
|
||||
/// Resolves the OSS provider from CLI override, profile config, or global config.
|
||||
/// Returns `None` if no provider is configured at any level.
|
||||
pub fn resolve_oss_provider(
|
||||
@@ -2011,6 +2049,8 @@ impl Config {
|
||||
codex_home: PathBuf,
|
||||
config_layer_stack: ConfigLayerStack,
|
||||
) -> std::io::Result<Self> {
|
||||
validate_reserved_model_provider_ids(&cfg.model_providers)
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
// Ensure that every field of ConfigRequirements is applied to the final
|
||||
// Config.
|
||||
let ConfigRequirements {
|
||||
|
||||
@@ -386,6 +386,34 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
|
||||
assert_eq!(contents.trim(), "model = \"user\"");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reserved_builtin_provider_override_rejected() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap();
|
||||
|
||||
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
|
||||
let error = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
key_path: "model_providers.openai.name".to_string(),
|
||||
value: serde_json::json!("OpenAI Override"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("should reject reserved provider override");
|
||||
|
||||
assert_eq!(
|
||||
error.write_error_code(),
|
||||
Some(ConfigWriteErrorCode::ConfigValidationError)
|
||||
);
|
||||
assert!(error.to_string().contains("reserved built-in provider IDs"));
|
||||
assert!(error.to_string().contains("`openai`"));
|
||||
|
||||
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
assert_eq!(contents, "model = \"user\"\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_value_rejects_feature_requirement_conflict() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -43,7 +46,7 @@ pub struct PluginManifestInterfaceSummary {
|
||||
pub website_url: Option<String>,
|
||||
pub privacy_policy_url: Option<String>,
|
||||
pub terms_of_service_url: Option<String>,
|
||||
pub default_prompt: Option<String>,
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
@@ -75,7 +78,7 @@ struct PluginManifestInterface {
|
||||
#[serde(alias = "termsOfServiceURL")]
|
||||
terms_of_service_url: Option<String>,
|
||||
#[serde(default)]
|
||||
default_prompt: Option<String>,
|
||||
default_prompt: Option<PluginManifestDefaultPrompt>,
|
||||
#[serde(default)]
|
||||
brand_color: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -86,6 +89,21 @@ struct PluginManifestInterface {
|
||||
screenshots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PluginManifestDefaultPrompt {
|
||||
String(String),
|
||||
List(Vec<PluginManifestDefaultPromptEntry>),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PluginManifestDefaultPromptEntry {
|
||||
String(String),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
@@ -128,7 +146,7 @@ pub(crate) fn plugin_manifest_interface(
|
||||
website_url: interface.website_url.clone(),
|
||||
privacy_policy_url: interface.privacy_policy_url.clone(),
|
||||
terms_of_service_url: interface.terms_of_service_url.clone(),
|
||||
default_prompt: interface.default_prompt.clone(),
|
||||
default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()),
|
||||
brand_color: interface.brand_color.clone(),
|
||||
composer_icon: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
@@ -190,6 +208,99 @@ fn resolve_interface_asset_path(
|
||||
resolve_manifest_path(plugin_root, field, path)
|
||||
}
|
||||
|
||||
fn resolve_default_prompts(
|
||||
plugin_root: &Path,
|
||||
value: Option<&PluginManifestDefaultPrompt>,
|
||||
) -> Option<Vec<String>> {
|
||||
match value? {
|
||||
PluginManifestDefaultPrompt::String(prompt) => {
|
||||
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
|
||||
.map(|prompt| vec![prompt])
|
||||
}
|
||||
PluginManifestDefaultPrompt::List(values) => {
|
||||
let mut prompts = Vec::new();
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
match item {
|
||||
PluginManifestDefaultPromptEntry::String(prompt) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
if let Some(prompt) =
|
||||
resolve_default_prompt_str(plugin_root, &field, prompt)
|
||||
{
|
||||
prompts.push(prompt);
|
||||
}
|
||||
}
|
||||
PluginManifestDefaultPromptEntry::Invalid(value) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
&field,
|
||||
&format!("expected a string, found {}", json_value_type(value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(!prompts.is_empty()).then_some(prompts)
|
||||
}
|
||||
PluginManifestDefaultPrompt::Invalid(value) => {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!(
|
||||
"expected a string or array of strings, found {}",
|
||||
json_value_type(value)
|
||||
),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
|
||||
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if prompt.is_empty() {
|
||||
warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty");
|
||||
return None;
|
||||
}
|
||||
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
field,
|
||||
&format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
"ignoring {field}: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
fn json_value_type(value: &JsonValue) -> &'static str {
|
||||
match value {
|
||||
JsonValue::Null => "null",
|
||||
JsonValue::Bool(_) => "boolean",
|
||||
JsonValue::Number(_) => "number",
|
||||
JsonValue::String(_) => "string",
|
||||
JsonValue::Array(_) => "array",
|
||||
JsonValue::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_manifest_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
@@ -232,3 +343,112 @@ fn resolve_manifest_path(
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_DEFAULT_PROMPT_LEN;
|
||||
use super::PluginManifest;
|
||||
use super::plugin_manifest_interface;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_manifest(plugin_root: &Path, interface: &str) {
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "demo-plugin",
|
||||
"interface": {interface}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write manifest");
|
||||
}
|
||||
|
||||
fn load_manifest(plugin_root: &Path) -> PluginManifest {
|
||||
let manifest_path = plugin_root.join(".codex-plugin/plugin.json");
|
||||
let contents = fs::read_to_string(manifest_path).expect("read manifest");
|
||||
serde_json::from_str(&contents).expect("parse manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_accepts_legacy_default_prompt_string() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": " Summarize my inbox "
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec!["Summarize my inbox".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_normalizes_default_prompt_array() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
&format!(
|
||||
r#"{{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": [
|
||||
" Summarize my inbox ",
|
||||
123,
|
||||
"{too_long}",
|
||||
" ",
|
||||
"Draft the reply ",
|
||||
"Find my next action",
|
||||
"Archive old mail"
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec![
|
||||
"Summarize my inbox".to_string(),
|
||||
"Draft the reply".to_string(),
|
||||
"Find my next action".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_ignores_invalid_default_prompt_shape() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": { "text": "Summarize my inbox" }
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
|
||||
assert_eq!(interface.default_prompt, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,6 +1851,7 @@ async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> {
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: false,
|
||||
}])
|
||||
.await;
|
||||
if !turn
|
||||
@@ -1949,6 +1950,7 @@ async fn js_repl_dynamic_tool_response_preserves_js_line_separator_text() -> any
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: false,
|
||||
}])
|
||||
.await;
|
||||
|
||||
@@ -2008,6 +2010,79 @@ console.log(text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_can_call_hidden_dynamic_tools() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (session, turn, rx_event) =
|
||||
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
description: "A hidden dynamic tool.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
}])
|
||||
.await;
|
||||
|
||||
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
let code = r#"
|
||||
const out = await codex.tool("hidden_dynamic_tool", { city: "Paris" });
|
||||
console.log(JSON.stringify(out));
|
||||
"#;
|
||||
|
||||
let session_for_response = Arc::clone(&session);
|
||||
let response_watcher = async move {
|
||||
loop {
|
||||
let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??;
|
||||
if let EventMsg::DynamicToolCallRequest(request) = event.msg {
|
||||
session_for_response
|
||||
.notify_dynamic_tool_response(
|
||||
&request.call_id,
|
||||
DynamicToolResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: "hidden-ok".to_string(),
|
||||
}],
|
||||
success: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (result, response_watcher_result) = tokio::join!(
|
||||
manager.execute(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
tracker,
|
||||
JsReplArgs {
|
||||
code: code.to_string(),
|
||||
timeout_ms: Some(15_000),
|
||||
},
|
||||
),
|
||||
response_watcher,
|
||||
);
|
||||
|
||||
let result = result?;
|
||||
response_watcher_result?;
|
||||
assert!(result.output.contains("hidden-ok"));
|
||||
assert!(session.get_pending_input().await.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
|
||||
@@ -4,6 +4,14 @@ use anyhow::Result;
|
||||
use codex_core::config::types::McpServerConfig;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
@@ -17,6 +25,8 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -91,18 +101,39 @@ fn custom_tool_output_body_and_success(
|
||||
req: &ResponsesRequest,
|
||||
call_id: &str,
|
||||
) -> (String, Option<bool>) {
|
||||
let (_, success) = req
|
||||
let (content, success) = req
|
||||
.custom_tool_call_output_content_and_success(call_id)
|
||||
.expect("custom tool output should be present");
|
||||
let items = custom_tool_output_items(req, call_id);
|
||||
let output = items
|
||||
let text_items = items
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|item| item.get("text").and_then(Value::as_str))
|
||||
.collect();
|
||||
.collect::<Vec<_>>();
|
||||
let output = match text_items.as_slice() {
|
||||
[] => content.unwrap_or_default(),
|
||||
[only] => (*only).to_string(),
|
||||
[_, rest @ ..] => rest.concat(),
|
||||
};
|
||||
(output, success)
|
||||
}
|
||||
|
||||
fn custom_tool_output_last_non_empty_text(req: &ResponsesRequest, call_id: &str) -> Option<String> {
|
||||
match req.custom_tool_call_output(call_id).get("output") {
|
||||
Some(Value::String(text)) if !text.trim().is_empty() => Some(text.clone()),
|
||||
Some(Value::Array(items)) => items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("text").and_then(Value::as_str))
|
||||
.rfind(|text| !text.trim().is_empty())
|
||||
.map(str::to_string),
|
||||
Some(Value::String(_))
|
||||
| Some(Value::Object(_))
|
||||
| Some(Value::Number(_))
|
||||
| Some(Value::Bool(_))
|
||||
| Some(Value::Null)
|
||||
| None => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_code_mode_turn(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
@@ -1506,6 +1537,10 @@ text({ json: true });
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
|
||||
eprintln!(
|
||||
"hidden dynamic tool raw output: {}",
|
||||
req.custom_tool_call_output("call-1")
|
||||
);
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
@@ -1920,7 +1955,10 @@ text(JSON.stringify(tool));
|
||||
"exec ALL_TOOLS lookup failed unexpectedly: {output}"
|
||||
);
|
||||
|
||||
let parsed: Value = serde_json::from_str(&output)?;
|
||||
let parsed: Value = serde_json::from_str(
|
||||
&custom_tool_output_last_non_empty_text(&req, "call-1")
|
||||
.expect("exec ALL_TOOLS lookup should emit JSON"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
parsed,
|
||||
serde_json::json!({
|
||||
@@ -1955,7 +1993,10 @@ text(JSON.stringify(tool));
|
||||
"exec ALL_TOOLS MCP lookup failed unexpectedly: {output}"
|
||||
);
|
||||
|
||||
let parsed: Value = serde_json::from_str(&output)?;
|
||||
let parsed: Value = serde_json::from_str(
|
||||
&custom_tool_output_last_non_empty_text(&req, "call-1")
|
||||
.expect("exec ALL_TOOLS MCP lookup should emit JSON"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
parsed,
|
||||
serde_json::json!({
|
||||
@@ -1967,6 +2008,159 @@ text(JSON.stringify(tool));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
});
|
||||
let base_test = builder.build(&server).await?;
|
||||
let new_thread = base_test
|
||||
.thread_manager
|
||||
.start_thread_with_tools(
|
||||
base_test.config.clone(),
|
||||
vec![DynamicToolSpec {
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
description: "A hidden dynamic tool.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
}],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let test = TestCodex {
|
||||
home: base_test.home,
|
||||
cwd: base_test.cwd,
|
||||
codex: new_thread.thread,
|
||||
session_configured: new_thread.session_configured,
|
||||
config: base_test.config,
|
||||
thread_manager: base_test.thread_manager,
|
||||
};
|
||||
|
||||
let code = r#"
|
||||
import { ALL_TOOLS, hidden_dynamic_tool } from "tools.js";
|
||||
|
||||
const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool");
|
||||
const out = await hidden_dynamic_tool({ city: "Paris" });
|
||||
text(
|
||||
JSON.stringify({
|
||||
name: tool?.name ?? null,
|
||||
description: tool?.description ?? null,
|
||||
out,
|
||||
})
|
||||
);
|
||||
"#;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let second_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "use exec to inspect and call hidden tools".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turn_id = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let request = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::DynamicToolCallRequest(request) => Some(request.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(request.tool, "hidden_dynamic_tool");
|
||||
assert_eq!(request.arguments, serde_json::json!({ "city": "Paris" }));
|
||||
test.codex
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: request.call_id,
|
||||
response: DynamicToolResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: "hidden-ok".to_string(),
|
||||
}],
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| match event {
|
||||
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"exec hidden dynamic tool call failed unexpectedly: {output}"
|
||||
);
|
||||
|
||||
let parsed: Value = serde_json::from_str(
|
||||
&custom_tool_output_last_non_empty_text(&req, "call-1")
|
||||
.expect("exec hidden dynamic tool lookup should emit JSON"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
parsed.get("name"),
|
||||
Some(&Value::String("hidden_dynamic_tool".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get("out"),
|
||||
Some(&Value::String("hidden-ok".to_string()))
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|description| {
|
||||
description.contains("A hidden dynamic tool.")
|
||||
&& description.contains("declare const tools:")
|
||||
&& description.contains("hidden_dynamic_tool(args:")
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_print_content_only_mcp_tool_result_fields() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -2130,7 +2324,10 @@ text(JSON.stringify(load("nb")));
|
||||
Some(false),
|
||||
"exec load call failed unexpectedly: {second_output}"
|
||||
);
|
||||
let loaded: Value = serde_json::from_str(&second_output)?;
|
||||
let loaded: Value = serde_json::from_str(
|
||||
&custom_tool_output_last_non_empty_text(&second_request, "call-2")
|
||||
.expect("exec load call should emit JSON"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
loaded,
|
||||
serde_json::json!({ "title": "Notebook", "items": [1, true, null] })
|
||||
|
||||
@@ -110,6 +110,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
|
||||
"required": ["city"],
|
||||
"properties": { "city": { "type": "string" } }
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
DynamicToolSpec {
|
||||
name: "weather_lookup".to_string(),
|
||||
@@ -119,6 +120,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
|
||||
"required": ["zip"],
|
||||
"properties": { "zip": { "type": "string" } }
|
||||
}),
|
||||
defer_loading: false,
|
||||
},
|
||||
];
|
||||
let dynamic_tools_for_hook = dynamic_tools.clone();
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynamicToolSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: JsonValue,
|
||||
#[serde(default)]
|
||||
pub defer_loading: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
@@ -37,3 +40,92 @@ pub enum DynamicToolCallOutputContentItem {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
InputImage { image_url: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DynamicToolSpecDe {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: JsonValue,
|
||||
defer_loading: Option<bool>,
|
||||
expose_to_context: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let DynamicToolSpecDe {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading,
|
||||
expose_to_context,
|
||||
} = DynamicToolSpecDe::deserialize(deserializer)?;
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading: defer_loading
|
||||
.unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DynamicToolSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_deserializes_defer_loading() {
|
||||
let value = json!({
|
||||
"name": "lookup_ticket",
|
||||
"description": "Fetch a ticket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"deferLoading": true,
|
||||
});
|
||||
|
||||
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
DynamicToolSpec {
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
defer_loading: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() {
|
||||
let value = json!({
|
||||
"name": "lookup_ticket",
|
||||
"description": "Fetch a ticket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"exposeToContext": false,
|
||||
});
|
||||
|
||||
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert!(actual.defer_loading);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE thread_dynamic_tools
|
||||
ADD COLUMN defer_loading INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -50,7 +50,7 @@ WHERE id = ?
|
||||
) -> anyhow::Result<Option<Vec<DynamicToolSpec>>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT name, description, input_schema
|
||||
SELECT name, description, input_schema, defer_loading
|
||||
FROM thread_dynamic_tools
|
||||
WHERE thread_id = ?
|
||||
ORDER BY position ASC
|
||||
@@ -70,6 +70,7 @@ ORDER BY position ASC
|
||||
name: row.try_get("name")?,
|
||||
description: row.try_get("description")?,
|
||||
input_schema,
|
||||
defer_loading: row.try_get("defer_loading")?,
|
||||
});
|
||||
}
|
||||
Ok(Some(tools))
|
||||
@@ -425,8 +426,9 @@ INSERT INTO thread_dynamic_tools (
|
||||
position,
|
||||
name,
|
||||
description,
|
||||
input_schema
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
input_schema,
|
||||
defer_loading
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(thread_id, position) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
@@ -435,6 +437,7 @@ ON CONFLICT(thread_id, position) DO NOTHING
|
||||
.bind(tool.name.as_str())
|
||||
.bind(tool.description.as_str())
|
||||
.bind(input_schema)
|
||||
.bind(tool.defer_loading)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ from .v2_all import FuzzyFileSearchSessionUpdatedNotification
|
||||
from .v2_all import HookCompletedNotification
|
||||
from .v2_all import HookStartedNotification
|
||||
from .v2_all import ItemCompletedNotification
|
||||
from .v2_all import ItemGuardianApprovalReviewCompletedNotification
|
||||
from .v2_all import ItemGuardianApprovalReviewStartedNotification
|
||||
from .v2_all import ItemStartedNotification
|
||||
from .v2_all import McpServerOauthLoginCompletedNotification
|
||||
from .v2_all import McpToolCallProgressNotification
|
||||
@@ -66,6 +68,8 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"hook/completed": HookCompletedNotification,
|
||||
"hook/started": HookStartedNotification,
|
||||
"item/agentMessage/delta": AgentMessageDeltaNotification,
|
||||
"item/autoApprovalReview/completed": ItemGuardianApprovalReviewCompletedNotification,
|
||||
"item/autoApprovalReview/started": ItemGuardianApprovalReviewStartedNotification,
|
||||
"item/commandExecution/outputDelta": CommandExecutionOutputDeltaNotification,
|
||||
"item/commandExecution/terminalInteraction": TerminalInteractionNotification,
|
||||
"item/completed": ItemCompletedNotification,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,7 @@ def test_python_codegen_schema_annotation_adds_stable_variant_titles() -> None:
|
||||
]
|
||||
assert ask_for_approval_titles == [
|
||||
"AskForApprovalValue",
|
||||
"RejectAskForApproval",
|
||||
"GranularAskForApproval",
|
||||
]
|
||||
|
||||
reasoning_summary_titles = [
|
||||
|
||||
Reference in New Issue
Block a user