Preserve namespaces on custom tool calls (#30302)

## Summary

- Preserve the optional namespace on custom tool calls during response
deserialization and app-server replay.
- Use the namespaced tool identifier for streaming argument handling and
tool dispatch.
- Regenerate app-server protocol schemas.
- Add regression tests covering namespace serialization and routing.

## Testing

- Ran affected protocol and app-server test suites.
- Ran the full core test suite; two load-sensitive timing tests passed
when rerun individually.
- Ran Clippy and formatting checks.
- Verified with a local end-to-end app-server replay that the namespace
is preserved through the complete request/response flow.
This commit is contained in:
nhamidi-oai
2026-06-27 09:54:56 -07:00
committed by GitHub
Unverified
parent c464468493
commit 328e95110c
21 changed files with 217 additions and 38 deletions
+14 -8
View File
@@ -2683,6 +2683,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -3631,6 +3637,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -3673,13 +3686,6 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -6873,4 +6879,4 @@
}
],
"title": "ClientRequest"
}
}
@@ -15510,6 +15510,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -17279,6 +17285,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -17321,13 +17334,6 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -20874,4 +20880,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -11914,6 +11914,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -15058,6 +15064,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -15100,13 +15113,6 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -18652,4 +18658,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
@@ -751,6 +751,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -126,6 +126,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -168,13 +175,6 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -182,4 +182,4 @@
],
"title": "ThreadForkParams",
"type": "object"
}
}
@@ -821,6 +821,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -20,4 +20,4 @@ id?: string,
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" };
@@ -80,6 +80,7 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
status: None,
call_id: "call-1".to_string(),
name: "tool".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
@@ -513,6 +513,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
status: None,
call_id: "tool-1".to_string(),
name: "js_repl".to_string(),
namespace: None,
input: "view_image".to_string(),
internal_chat_message_metadata_passthrough: None,
},
@@ -582,6 +583,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
status: None,
call_id: "tool-1".to_string(),
name: "js_repl".to_string(),
namespace: None,
input: "view_image".to_string(),
internal_chat_message_metadata_passthrough: None,
},
@@ -1090,6 +1092,7 @@ fn remove_first_item_handles_custom_tool_pair() {
status: None,
call_id: "tool-1".to_string(),
name: "my_tool".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
@@ -1378,6 +1381,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() {
status: None,
call_id: "tool-x".to_string(),
name: "custom".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
}];
@@ -1393,6 +1397,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() {
status: None,
call_id: "tool-x".to_string(),
name: "custom".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
@@ -1512,6 +1517,7 @@ fn normalize_mixed_inserts_and_removals() {
status: None,
call_id: "t1".to_string(),
name: "tool".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
@@ -1556,6 +1562,7 @@ fn normalize_mixed_inserts_and_removals() {
status: None,
call_id: "t1".to_string(),
name: "tool".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
@@ -1668,6 +1675,7 @@ fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() {
status: None,
call_id: "tool-x".to_string(),
name: "custom".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
}];
@@ -1810,6 +1818,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() {
status: None,
call_id: "t1".to_string(),
name: "tool".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
+1
View File
@@ -10482,6 +10482,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
status: None,
call_id: "call-1".to_string(),
name: "shell_command".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
};
+8 -2
View File
@@ -2080,8 +2080,14 @@ async fn try_run_sampling_request(
}
}
ResponseEvent::OutputItemAdded(item) => {
if let ResponseItem::CustomToolCall { call_id, name, .. } = &item {
let tool_name = ToolName::plain(name.as_str());
if let ResponseItem::CustomToolCall {
call_id,
name,
namespace,
..
} = &item
{
let tool_name = ToolName::new(namespace.clone(), name.as_str());
active_tool_argument_diff_consumer = tool_runtime
.create_diff_consumer(&tool_name)
.map(|consumer| (call_id.clone(), consumer));
@@ -116,6 +116,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() {
status: None,
call_id: "custom-1".to_string(),
name: "apply_patch".to_string(),
namespace: None,
input: "*** Begin Patch\n*** End Patch\n".to_string(),
internal_chat_message_metadata_passthrough: None,
},
+3 -2
View File
@@ -25,7 +25,7 @@ use tracing::instrument;
pub use crate::tools::context::ToolCallSource;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct ToolCall {
pub tool_name: ToolName,
pub call_id: String,
@@ -147,11 +147,12 @@ impl ToolRouter {
ResponseItem::ToolSearchCall { .. } => Ok(None),
ResponseItem::CustomToolCall {
name,
namespace,
input,
call_id,
..
} => Ok(Some(ToolCall {
tool_name: ToolName::plain(name),
tool_name: ToolName::new(namespace, name),
call_id,
payload: ToolPayload::Custom { input },
})),
+29
View File
@@ -179,6 +179,35 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()
Ok(())
}
#[tokio::test]
async fn build_custom_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> {
let tool_name = "exec".to_string();
let call = ToolRouter::build_tool_call(ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-namespace".to_string(),
name: tool_name.clone(),
namespace: Some("mcp__python".to_string()),
input: "print('hello')".to_string(),
internal_chat_message_metadata_passthrough: None,
})?
.expect("custom_tool_call should produce a tool call");
assert_eq!(
call,
ToolCall {
tool_name: ToolName::namespaced("mcp__python", tool_name),
call_id: "call-namespace".to_string(),
payload: ToolPayload::Custom {
input: "print('hello')".to_string(),
},
}
);
Ok(())
}
#[tokio::test]
async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> {
let (_, turn) = make_session_and_context().await;
+1
View File
@@ -121,6 +121,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() {
status: None,
call_id: "call-2".to_string(),
name: "custom".to_string(),
namespace: None,
input: "echo hi".to_string(),
internal_chat_message_metadata_passthrough: None,
}
+18
View File
@@ -895,6 +895,24 @@ pub fn ev_custom_tool_call(call_id: &str, name: &str, input: &str) -> Value {
})
}
pub fn ev_custom_tool_call_with_namespace(
call_id: &str,
namespace: &str,
name: &str,
input: &str,
) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"call_id": call_id,
"namespace": namespace,
"name": name,
"input": input
}
})
}
pub fn ev_local_shell_call(call_id: &str, status: &str, command: Vec<&str>) -> Value {
serde_json::json!({
"type": "response.output_item.done",
+3
View File
@@ -785,6 +785,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() {
status: None,
call_id: "legacy-js-call".to_string(),
name: "js_repl".to_string(),
namespace: None,
input: "console.log('legacy image flow')".to_string(),
internal_chat_message_metadata_passthrough: None,
};
@@ -976,6 +977,7 @@ async fn resume_replays_image_tool_outputs_with_detail() {
status: Some("completed".to_string()),
call_id: custom_call_id.to_string(),
name: "js_repl".to_string(),
namespace: None,
input: "console.log('image flow')".to_string(),
internal_chat_message_metadata_passthrough: None,
}),
@@ -2912,6 +2914,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
status: Some("completed".into()),
call_id: "custom-tool-call-id".into(),
name: "custom_tool".into(),
namespace: None,
input: "{}".into(),
internal_chat_message_metadata_passthrough: None,
});
+75
View File
@@ -20,6 +20,7 @@ use core_test_support::assert_regex_match;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_custom_tool_call_with_namespace;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
@@ -177,6 +178,80 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn namespaced_custom_tool_call_preserves_namespace_through_dispatch_and_replay() -> Result<()>
{
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build(&server).await?;
let call_id = "custom-namespaced";
let namespace = "test_namespace::";
let tool_name = "unsupported_tool";
let input = "\"payload\"";
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call_with_namespace(call_id, namespace, tool_name, input),
ev_completed("resp-1"),
]),
)
.await;
let mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn_with_approval_and_permission_profile(
"invoke namespaced custom tool",
AskForApproval::Never,
PermissionProfile::Disabled,
)
.await?;
let request = mock.single_request();
let custom_tool_calls = request.inputs_of_type("custom_tool_call");
let turn_id = custom_tool_calls
.first()
.and_then(|item| item.pointer("/internal_chat_message_metadata_passthrough/turn_id"))
.and_then(Value::as_str)
.map(str::to_string)
.expect("custom tool call should include turn metadata");
assert_eq!(
(custom_tool_calls, request.custom_tool_call_output(call_id),),
(
vec![json!({
"type": "custom_tool_call",
"call_id": call_id,
"namespace": namespace,
"name": tool_name,
"input": input,
"internal_chat_message_metadata_passthrough": {
"turn_id": turn_id,
},
})],
json!({
"type": "custom_tool_call_output",
"call_id": call_id,
"output": format!("unsupported custom tool call: {namespace}{tool_name}"),
"internal_chat_message_metadata_passthrough": {
"turn_id": turn_id,
},
}),
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -97,6 +97,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() {
status: Some("completed".to_string()),
call_id: "code-mode-call".to_string(),
name: "exec".to_string(),
namespace: None,
input: String::new(),
internal_chat_message_metadata_passthrough: None,
},
+3
View File
@@ -1052,6 +1052,9 @@ pub enum ResponseItem {
call_id: String,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
namespace: Option<String>,
input: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
+1 -1
View File
@@ -3,7 +3,7 @@ use std::borrow::Cow;
use codex_protocol::models::SearchToolCallParams;
/// Canonical payload shapes accepted by model-visible tool runtimes.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub enum ToolPayload {
Function { arguments: String },
ToolSearch { arguments: SearchToolCallParams },