diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index c5fe07c1d1..047f8267fc 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3051,7 +3051,10 @@ { "properties": { "call_id": { - "type": "string" + "type": [ + "string", + "null" + ] }, "id": { "type": [ @@ -3069,6 +3072,18 @@ } ] }, + "name": { + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "output": { "$ref": "#/definitions/FunctionCallOutputBody" }, @@ -3081,7 +3096,6 @@ } }, "required": [ - "call_id", "output", "type" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 15d25e6bb6..3edb865667 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -17372,7 +17372,10 @@ { "properties": { "call_id": { - "type": "string" + "type": [ + "string", + "null" + ] }, "id": { "type": [ @@ -17390,6 +17393,18 @@ } ] }, + "name": { + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "output": { "$ref": "#/definitions/v2/FunctionCallOutputBody" }, @@ -17402,7 +17417,6 @@ } }, "required": [ - "call_id", "output", "type" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index fac84497ae..032dfbc38a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13582,7 +13582,10 @@ { "properties": { "call_id": { - "type": "string" + "type": [ + "string", + "null" + ] }, "id": { "type": [ @@ -13600,6 +13603,18 @@ } ] }, + "name": { + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "output": { "$ref": "#/definitions/FunctionCallOutputBody" }, @@ -13612,7 +13627,6 @@ } }, "required": [ - "call_id", "output", "type" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index d4a19a0aa2..9b1afc5ea0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -736,7 +736,10 @@ { "properties": { "call_id": { - "type": "string" + "type": [ + "string", + "null" + ] }, "id": { "type": [ @@ -754,6 +757,18 @@ } ] }, + "name": { + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "output": { "$ref": "#/definitions/FunctionCallOutputBody" }, @@ -766,7 +781,6 @@ } }, "required": [ - "call_id", "output", "type" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 0bce28ec6a..00c06229a1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -806,7 +806,10 @@ { "properties": { "call_id": { - "type": "string" + "type": [ + "string", + "null" + ] }, "id": { "type": [ @@ -824,6 +827,18 @@ } ] }, + "name": { + "type": [ + "string", + "null" + ] + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "output": { "$ref": "#/definitions/FunctionCallOutputBody" }, @@ -836,7 +851,6 @@ } }, "required": [ - "call_id", "output", "type" ], diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 946916b6ab..a5690d7291 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 935759250b..2e37790d0b 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 9e7d0dccf4..178cd65b1e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -21,4 +21,4 @@ id?: ResponseItemId, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: ResponseItemId, name: string, namespace?: string, arguments: string, encrypted_function_args?: Array, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: ResponseItemId, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: ResponseItemId, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: ResponseItemId, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: ResponseItemId, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: ResponseItemId, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: ResponseItemId, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: ResponseItemId, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: ResponseItemId, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: ResponseItemId, 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?: ResponseItemId, name: string, namespace?: string, arguments: string, encrypted_function_args?: Array, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: ResponseItemId, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: ResponseItemId, call_id?: string, name?: string, namespace?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: ResponseItemId, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: ResponseItemId, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: ResponseItemId, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: ResponseItemId, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: ResponseItemId, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: ResponseItemId, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: ResponseItemId, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 10aa1e6675..5b60156f21 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1018,7 +1018,7 @@ Invoke a plugin by including a UI mention token such as `@sample` in the text in ### Example: Inject raw history items -Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. Any `input_image` items must use inline data URLs; remote HTTP(S) image URLs are rejected. +Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. A standalone `function_call_output` can omit `call_id` when it has a nonempty `name`; `namespace` is optional, and the output retains tool-tier authority. Any `input_image` items must use inline data URLs; remote HTTP(S) image URLs are rejected. ```json { "method": "thread/inject_items", "id": 36, "params": { @@ -1028,6 +1028,12 @@ Use `thread/inject_items` to append prebuilt Responses API items to a loaded thr "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Previously computed context." }] + }, + { + "type": "function_call_output", + "name": "send_message_to_thread", + "namespace": "codex_app", + "output": "Another agent delegated this task." } ] } } diff --git a/codex-rs/app-server/tests/suite/v2/request_validation.rs b/codex-rs/app-server/tests/suite/v2/request_validation.rs index 2a709453e6..1b5abe2f17 100644 --- a/codex-rs/app-server/tests/suite/v2/request_validation.rs +++ b/codex-rs/app-server/tests/suite/v2/request_validation.rs @@ -168,7 +168,9 @@ async fn request_handlers_reject_remote_image_urls() -> Result<()> { let remote_tool_output = serde_json::to_value(ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/tool.png".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index ec8d08ba39..b511dff55b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -21,6 +21,7 @@ use core_test_support::responses; use core_test_support::responses::strip_response_item_id; use core_test_support::responses::strip_response_item_ids_from_json; use serde_json::Value; +use serde_json::json; use std::collections::HashMap; use tempfile::TempDir; use tokio::time::timeout; @@ -78,6 +79,13 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu let injected_developer_item = developer_item("Injected developer context"); let marker_shaped_developer_item = developer_item("\nclient message\n"); + let named_tool_output = json!({ + "type": "function_call_output", + "name": "send_message_to_thread", + "namespace": "codex_app", + "output": "Another agent delegated this task.", + }); + let named_tool_item: ResponseItem = serde_json::from_value(named_tool_output.clone())?; let inject_req = mcp .send_thread_inject_items_request(ThreadInjectItemsParams { @@ -86,6 +94,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu serde_json::to_value(&injected_item)?, serde_json::to_value(&injected_developer_item)?, serde_json::to_value(&marker_shaped_developer_item)?, + named_tool_output.clone(), ], }) .await?; @@ -114,6 +123,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu item == &injected_item || item == &injected_developer_item || item == &marker_shaped_developer_item + || item == &named_tool_item }) .collect::>(); assert_eq!( @@ -122,6 +132,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu (injected_item.clone(), None), (injected_developer_item.clone(), Some(true)), (marker_shaped_developer_item.clone(), Some(true)), + (named_tool_item, None), ] ); @@ -233,6 +244,10 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu injected_index < user_prompt_index, "injected items should be sent before the user prompt" ); + assert!( + model_input.contains(&named_tool_output), + "named unpaired tool output should be sent in the next model request" + ); Ok(()) } diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index e18e760f1e..c116542433 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -59,13 +59,16 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: "assistant" => *phase == Some(MessagePhase::FinalAnswer), _ => false, }, + ResponseItem::FunctionCallOutput { call_id: None, .. } => true, ResponseItem::AdditionalTools { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::FunctionCallOutput { + call_id: Some(_), .. + } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 4cbc4a46d5..4ab6bc0509 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -35,6 +35,7 @@ use codex_protocol::error::CodexErrorDetails; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::MessagePhase; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; @@ -1352,6 +1353,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "parent trigger message".to_string(), /*trigger_turn*/ true, ); + let standalone_output = ResponseItem::FunctionCallOutput { + id: None, + call_id: None, + name: Some("notifications".to_string()), + namespace: Some("slack".to_string()), + output: FunctionCallOutputPayload::from_text("parent notification".to_string()), + internal_chat_message_metadata_passthrough: None, + }; parent_thread .session .record_conversation_items( @@ -1399,6 +1408,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + standalone_output, assistant_message("parent unknown phase", /*phase*/ None), ResponseItem::Reasoning { id: Some(ResponseItemId::with_suffix("rs", "parent-reasoning")), @@ -1412,6 +1422,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ], ) .await; + let expected_standalone_output = parent_thread + .session + .clone_history() + .await + .raw_items() + .find(|item| matches!(item, ResponseItem::FunctionCallOutput { call_id: None, .. })) + .cloned() + .expect("standalone output should be recorded"); let parent_reference_context_item = turn_context.to_turn_context_item(); parent_thread .session @@ -1494,6 +1512,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { expected_parent_seed, expected_developer_message, expected_final_answer, + expected_standalone_output, ResponseItem::Message { id: None, role: "developer".to_string(), diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 31d1b36db5..6f0dac2696 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -30,7 +30,9 @@ fn prompt_with_image_outputs() -> Prompt { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "function-call".to_string(), + call_id: Some("function-call".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,function".to_string(), @@ -78,7 +80,9 @@ fn responses_lite_request_copies_strip_image_details() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "function-call".to_string(), + call_id: Some("function-call".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,function".to_string(), diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 392fb991c1..eb686725c9 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -461,11 +461,15 @@ fn rewritten_output_for_context_window( ResponseItem::FunctionCallOutput { id, call_id, + name, + namespace, output, internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), + name: name.clone(), + namespace: namespace.clone(), output: truncated_output_payload(output), internal_chat_message_metadata_passthrough: metadata.clone(), }, diff --git a/codex-rs/core/src/compact_remote_metadata_tests.rs b/codex-rs/core/src/compact_remote_metadata_tests.rs index c3a9d41134..c1dde7d9d6 100644 --- a/codex-rs/core/src/compact_remote_metadata_tests.rs +++ b/codex-rs/core/src/compact_remote_metadata_tests.rs @@ -6,7 +6,9 @@ fn rewritten_output_preserves_harness_metadata() { let envelope = ResponseItemEnvelope { item: ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text("large output".repeat(100)), success: Some(true), diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 96ceff8da9..746896ea65 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -439,7 +439,7 @@ impl ContextManager { /// This function enforces a couple of invariants on the in-memory history: /// 1. every call (function/custom) has a corresponding output entry - /// 2. every output has a corresponding call entry + /// 2. every output has a corresponding call entry or names an external tool event /// 3. unsupported image and audio content is stripped from messages and tool outputs fn normalize_history(&mut self, input_modalities: &[InputModality]) { let items = Arc::make_mut(&mut self.items); @@ -447,7 +447,7 @@ impl ContextManager { // all function/tool calls must have a corresponding output normalize::ensure_call_outputs_present(items); - // all outputs must have a corresponding function/tool call + // Paired outputs must have a corresponding call; named external outputs stand alone. normalize::remove_orphan_outputs(items); // strip images when model does not support them @@ -463,11 +463,15 @@ impl ContextManager { ResponseItem::FunctionCallOutput { id, call_id, + name, + namespace, output, internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), + name: name.clone(), + namespace: namespace.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), internal_chat_message_metadata_passthrough: metadata.clone(), }, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index fa03ecb925..956b759a1a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -547,7 +547,9 @@ fn record_annotated_items_preserves_metadata_while_processing_item() { let envelope = ResponseItemEnvelope { item: ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text("word ".repeat(100)), success: Some(true), @@ -684,7 +686,9 @@ fn for_prompt_strips_media_when_model_does_not_support_it() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { text: "image result".to_string(), @@ -766,7 +770,9 @@ fn for_prompt_strips_media_when_model_does_not_support_it() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { text: "image result".to_string(), @@ -989,7 +995,9 @@ fn remove_first_item_removes_matching_output_for_function_call() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1004,7 +1012,9 @@ fn remove_first_item_removes_matching_call_for_output() { let items = vec![ ResponseItem::FunctionCallOutput { id: None, - call_id: "call-2".to_string(), + call_id: Some("call-2".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1041,7 +1051,9 @@ fn remove_first_item_handles_local_shell_pair() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-3".to_string(), + call_id: Some("call-3".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1338,7 +1350,9 @@ fn normalization_retains_local_shell_outputs() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "shell-1".to_string(), + call_id: Some("shell-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("Total output lines: 1\n\nok".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1360,7 +1374,9 @@ fn record_items_truncates_function_call_output_content() { let long_output = long_line.repeat(2_500); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-100".to_string(), + call_id: Some("call-100".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(long_output.clone()), success: Some(true), @@ -1433,7 +1449,9 @@ fn record_items_respects_custom_token_limit() { let long_output = "tokenized content repeated many times ".repeat(200); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-custom-limit".to_string(), + call_id: Some("call-custom-limit".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(long_output), success: Some(true), @@ -1579,7 +1597,9 @@ fn normalize_adds_missing_output_for_function_call() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-x".to_string(), + call_id: Some("call-x".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1664,7 +1684,9 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "shell-1".to_string(), + call_id: Some("shell-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1677,7 +1699,9 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { fn normalize_removes_orphan_function_call_output() { let items = vec![ResponseItem::FunctionCallOutput { id: None, - call_id: "orphan-1".to_string(), + call_id: Some("orphan-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }]; @@ -1722,7 +1746,9 @@ fn normalize_mixed_inserts_and_removals() { // Orphan output that should be removed ResponseItem::FunctionCallOutput { id: None, - call_id: "c2".to_string(), + call_id: Some("c2".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1769,7 +1795,9 @@ fn normalize_mixed_inserts_and_removals() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "c1".to_string(), + call_id: Some("c1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1804,7 +1832,9 @@ fn normalize_mixed_inserts_and_removals() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "s1".to_string(), + call_id: Some("s1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1839,7 +1869,9 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-x".to_string(), + call_id: Some("call-x".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -1847,6 +1879,21 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { ); } +#[test] +fn normalize_preserves_named_function_call_output_without_call_id() { + let item = ResponseItem::FunctionCallOutput { + id: None, + call_id: None, + name: Some("send_message_to_thread".to_string()), + namespace: Some("codex_app".to_string()), + output: FunctionCallOutputPayload::from_text("cross-thread message".to_string()), + internal_chat_message_metadata_passthrough: None, + }; + let history = create_history_with_items(vec![item.clone()]); + + assert_eq!(history.for_prompt(&default_input_modalities()), vec![item]); +} + #[test] fn for_prompt_assigns_stable_id_to_synthetic_output_without_reordering_history() { let items = vec![ @@ -1972,7 +2019,9 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() fn normalize_removes_orphan_function_call_output_panics_in_debug() { let items = vec![ResponseItem::FunctionCallOutput { id: None, - call_id: "orphan-1".to_string(), + call_id: Some("orphan-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }]; @@ -2072,7 +2121,9 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "c2".to_string(), + call_id: Some("c2".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -2148,7 +2199,9 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-abc".to_string(), + call_id: Some("call-abc".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { text: "Screenshot captured".to_string(), @@ -2221,7 +2274,9 @@ fn audio_data_url_payload_does_not_dominate_function_call_output_estimate() { let (audio_url, payload_len) = pcm_wav_data_url(/*sample_count*/ 800); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-audio".to_string(), + call_id: Some("call-audio".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputAudio { audio_url }, ]), @@ -2281,7 +2336,9 @@ fn record_items_omits_audio_that_exceeds_the_output_budget() { let (audio_url, _) = pcm_wav_data_url(/*sample_count*/ 80_000); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-audio".to_string(), + call_id: Some("call-audio".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputAudio { audio_url }, @@ -2298,7 +2355,9 @@ fn record_items_omits_audio_that_exceeds_the_output_budget() { raw_items(&history), &[ResponseItem::FunctionCallOutput { id: None, - call_id: "call-audio".to_string(), + call_id: Some("call-audio".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputText { @@ -2326,7 +2385,9 @@ fn non_base64_image_urls_are_unchanged() { }; let function_output_item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "file:///tmp/foo.png".to_string(), @@ -2351,7 +2412,9 @@ fn encrypted_function_output_uses_plaintext_byte_estimate() { let encrypted_content = "A".repeat(1_868); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-encrypted".to_string(), + call_id: Some("call-encrypted".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::EncryptedContent { encrypted_content: encrypted_content.clone(), @@ -2410,7 +2473,9 @@ fn non_image_base64_data_url_is_unchanged() { let image_url = format!("data:application/octet-stream;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-octet".to_string(), + call_id: Some("call-octet".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url, @@ -2499,7 +2564,9 @@ fn original_detail_images_scale_with_dimensions() { let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-original".to_string(), + call_id: Some("call-original".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url, @@ -2531,7 +2598,9 @@ fn original_detail_images_are_capped_at_max_patch_count() { let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-original-capped".to_string(), + call_id: Some("call-original-capped".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url, @@ -2566,7 +2635,9 @@ fn original_detail_webp_images_scale_with_dimensions() { let image_url = format!("data:image/webp;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-original-webp".to_string(), + call_id: Some("call-original-webp".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url, diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 8ceb8c9810..0e693ce09f 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -24,7 +24,10 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) let mut custom_tool_output_ids = HashSet::new(); for envelope in items.iter() { match &envelope.item { - ResponseItem::FunctionCallOutput { call_id, .. } => { + ResponseItem::FunctionCallOutput { + call_id: Some(call_id), + .. + } => { function_output_ids.insert(call_id.as_str()); } ResponseItem::ToolSearchOutput { @@ -55,7 +58,9 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) idx, ResponseItemEnvelope::new(ResponseItem::FunctionCallOutput { id: synthetic_output_id("fco", id.as_deref()), - call_id: call_id.clone(), + call_id: Some(call_id.clone()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), internal_chat_message_metadata_passthrough: None, }), @@ -109,7 +114,9 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) idx, ResponseItemEnvelope::new(ResponseItem::FunctionCallOutput { id: synthetic_output_id("fco", id.as_deref()), - call_id: call_id.clone(), + call_id: Some(call_id.clone()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), internal_chat_message_metadata_passthrough: None, }), @@ -174,9 +181,10 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec) { let mut orphan_positions = Vec::new(); for (position, envelope) in items.iter().enumerate() { match &envelope.item { - ResponseItem::FunctionCallOutput { call_id, .. } - if !function_call_ids.contains(call_id.as_str()) => - { + ResponseItem::FunctionCallOutput { + call_id: Some(call_id), + .. + } if !function_call_ids.contains(call_id.as_str()) => { error_or_panic(format!( "Orphan function call output for call id: {call_id}" )); @@ -223,12 +231,16 @@ pub(crate) fn remove_corresponding_for(items: &mut Vec, it matches!( i, ResponseItem::FunctionCallOutput { - call_id: existing, .. + call_id: Some(existing), + .. } if existing == call_id ) }); } - ResponseItem::FunctionCallOutput { call_id, .. } => { + ResponseItem::FunctionCallOutput { + call_id: Some(call_id), + .. + } => { if let Some(pos) = items.iter().position(|envelope| { matches!(&envelope.item, ResponseItem::FunctionCall { call_id: existing, .. } if existing == call_id) }) { @@ -294,7 +306,8 @@ pub(crate) fn remove_corresponding_for(items: &mut Vec, it matches!( i, ResponseItem::FunctionCallOutput { - call_id: existing, .. + call_id: Some(existing), + .. } if existing == call_id ) }); diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 1cbcce7f33..e82d6bf79f 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -566,7 +566,9 @@ pub(crate) fn collect_guardian_transcript_entries<'a>( ) }), ResponseItem::FunctionCallOutput { - call_id, output, .. + call_id: Some(call_id), + output, + .. } | ResponseItem::CustomToolCallOutput { call_id, output, .. diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 1e5a3cdadb..772e680ae4 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -358,7 +358,9 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc Vec { let mut metadata = Vec::new(); let mut prepared_items = Vec::with_capacity(items.len()); + let prepare_tool_output = + |output: &mut FunctionCallOutputPayload, + item_id: Option<&str>, + metadata: &mut Vec| { + output.content_items_mut().and_then(|content| { + let resized_images = prepare_tool_output_content( + content, + ImageOrigin { + message_role: None, + item_id, + }, + resize_notice_mode, + metadata, + mode, + ); + (!resized_images.is_empty()).then(|| { + ImageResizeNotice::new(ImageResizeNoticeSource::ToolOutput, resized_images) + }) + }) + }; for mut item in std::mem::take(items) { let resize_notice = match &mut item { ResponseItem::Message { role, content, .. } => { @@ -122,24 +143,10 @@ pub(crate) fn prepare_response_items( } ResponseItem::FunctionCallOutput { call_id, output, .. - } - | ResponseItem::CustomToolCallOutput { + } => prepare_tool_output(output, call_id.as_deref(), &mut metadata), + ResponseItem::CustomToolCallOutput { call_id, output, .. - } => output.content_items_mut().and_then(|content| { - let resized_images = prepare_tool_output_content( - content, - ImageOrigin { - message_role: None, - item_id: Some(call_id), - }, - resize_notice_mode, - &mut metadata, - mode, - ); - (!resized_images.is_empty()).then(|| { - ImageResizeNotice::new(ImageResizeNoticeSource::ToolOutput, resized_images) - }) - }), + } => prepare_tool_output(output, Some(call_id.as_str()), &mut metadata), ResponseItem::AdditionalTools { .. } | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 9919e41e3e..ee0b94c068 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -143,7 +143,9 @@ fn preparation_reports_tool_output_item_id() { let (image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32); let mut items = vec![ResponseItem::FunctionCallOutput { id: None, - call_id: call_id.to_string(), + call_id: Some(call_id.to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url, @@ -199,7 +201,9 @@ fn resize_notices_preserve_original_image_positions_and_skip_failed_images() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-image".to_string(), + call_id: Some("call-image".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,%%%".to_string(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9a303e5ba6..5ff5e23b69 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2383,7 +2383,9 @@ async fn prepares_image_failures_before_history_insertion() { .await; let item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputText { @@ -2421,7 +2423,9 @@ async fn prepares_image_failures_before_history_insertion() { assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand)); let expected = vec![ResponseItem::FunctionCallOutput { id: Some(id.clone()), - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputText { diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index affdc92120..95cce62951 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -504,7 +504,9 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti ResponseInputItem::FunctionCallOutput { call_id, output } => { Some(ResponseItem::FunctionCallOutput { id: None, - call_id: call_id.clone(), + call_id: Some(call_id.clone()), + name: None, + namespace: None, output: output.clone(), internal_chat_message_metadata_passthrough: None, }) @@ -524,7 +526,9 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti let output = output.as_function_call_output_payload(); Some(ResponseItem::FunctionCallOutput { id: None, - call_id: call_id.clone(), + call_id: Some(call_id.clone()), + name: None, + namespace: None, output, internal_chat_message_metadata_passthrough: None, }) diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index f4818d22bd..8181ef53bd 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -106,7 +106,9 @@ fn external_context_pollution_items_exclude_local_tool_calls() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, }, diff --git a/codex-rs/core/src/tools/executed_tool_calls.rs b/codex-rs/core/src/tools/executed_tool_calls.rs index 0a52fcc3a7..7434728e7e 100644 --- a/codex-rs/core/src/tools/executed_tool_calls.rs +++ b/codex-rs/core/src/tools/executed_tool_calls.rs @@ -208,7 +208,10 @@ impl ExecutedToolCallRecorder { break; } let call_id = match &*item { - ResponseItem::FunctionCallOutput { call_id, .. } + ResponseItem::FunctionCallOutput { + call_id: Some(call_id), + .. + } | ResponseItem::CustomToolCallOutput { call_id, .. } | ResponseItem::ToolSearchOutput { call_id: Some(call_id), @@ -266,7 +269,10 @@ impl ExecutedToolCallRecorder { state.retained_calls.clear(); for item in items { let call_id = match &*item { - ResponseItem::FunctionCallOutput { call_id, .. } + ResponseItem::FunctionCallOutput { + call_id: Some(call_id), + .. + } | ResponseItem::CustomToolCallOutput { call_id, .. } | ResponseItem::ToolSearchOutput { call_id: Some(call_id), diff --git a/codex-rs/core/src/tools/executed_tool_calls_tests.rs b/codex-rs/core/src/tools/executed_tool_calls_tests.rs index 36a6adcd8c..c266d7c390 100644 --- a/codex-rs/core/src/tools/executed_tool_calls_tests.rs +++ b/codex-rs/core/src/tools/executed_tool_calls_tests.rs @@ -77,7 +77,9 @@ fn executed_tool_call_recorder_bounds_pending_calls_and_preserves_overflow() { let mut items = [ResponseItem::FunctionCallOutput { id: None, - call_id: "bounded-output".to_string(), + call_id: Some("bounded-output".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text(String::new()), internal_chat_message_metadata_passthrough: None, }]; @@ -116,7 +118,9 @@ fn executed_tool_call_recorder_bounds_pending_calls_and_preserves_overflow() { let mut replayed_items = [ResponseItem::FunctionCallOutput { id: None, - call_id: "bounded-output".to_string(), + call_id: Some("bounded-output".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text(String::new()), internal_chat_message_metadata_passthrough: None, }]; @@ -162,7 +166,9 @@ fn executed_tool_call_recorder_bounds_retained_history_and_reports_omissions() { ); history.push(ResponseItem::FunctionCallOutput { id: None, - call_id, + call_id: Some(call_id), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text(String::new()), internal_chat_message_metadata_passthrough: None, }); diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 77d75124c5..c32d86000e 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -192,7 +192,9 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { assert!(!response_item_records_turn_ttft( &ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), internal_chat_message_metadata_passthrough: None, } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3a70fff441..0fcba50dbf 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1210,7 +1210,9 @@ async fn resume_replays_image_tool_outputs_with_detail() { ordinal: None, item: rollout_response_item(ResponseItem::FunctionCallOutput { id: None, - call_id: function_call_id.to_string(), + call_id: Some(function_call_id.to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: image_url.to_string(), @@ -3056,7 +3058,9 @@ async fn azure_responses_request_does_not_store_and_preserves_prefixed_item_ids( }); prompt.input.push(ResponseItem::FunctionCallOutput { id: None, - call_id: "function-call-id".into(), + call_id: Some("function-call-id".into()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("ok".into()), internal_chat_message_metadata_passthrough: None, }); diff --git a/codex-rs/core/tests/suite/tool_lifecycle.rs b/codex-rs/core/tests/suite/tool_lifecycle.rs index 9e5dee9c1f..f5f8d49169 100644 --- a/codex-rs/core/tests/suite/tool_lifecycle.rs +++ b/codex-rs/core/tests/suite/tool_lifecycle.rs @@ -142,7 +142,10 @@ async fn tool_start_receives_conversation_history() -> Result<()> { let second_history = &histories[1].items; assert!(second_history.iter().any(|item| matches!( item, - ResponseItem::FunctionCallOutput { call_id, .. } if call_id == first_call_id + ResponseItem::FunctionCallOutput { + call_id: Some(call_id), + .. + } if call_id == first_call_id ))); assert!(second_history.iter().any(|item| matches!( item, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 3b6cabfdef..c95600091e 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -184,7 +184,7 @@ async fn wait_for_raw_unified_exec_output( let content = wait_for_event_match(&test.codex, |event| match event { EventMsg::RawResponseItem(raw) => match &raw.item { ResponseItem::FunctionCallOutput { - call_id: output_call_id, + call_id: Some(output_call_id), output, .. } if output_call_id == call_id => output.text_content().map(str::to_string), diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 0f8fdcffad..8ec7e10ed8 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -918,7 +918,9 @@ max_recent_non_user_entries = 8 }, ResponseItem::FunctionCallOutput { id: None, - call_id: "previous-call".to_owned(), + call_id: Some("previous-call".to_owned()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("README.md".to_owned()), internal_chat_message_metadata_passthrough: None, }, @@ -1174,7 +1176,9 @@ async fn contributor_includes_configured_transcript_images() -> Result<()> { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "previous-call".to_owned(), + call_id: Some("previous-call".to_owned()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { text: "Screenshot captured.".to_owned(), @@ -1386,7 +1390,9 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< }, ResponseItem::FunctionCallOutput { id: None, - call_id: "previous-call".to_owned(), + call_id: Some("previous-call".to_owned()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("README.md".to_owned()), internal_chat_message_metadata_passthrough: None, }, @@ -1662,7 +1668,9 @@ async fn contributor_sends_compacted_conversation_history_to_luna() -> Result<() }, ResponseItem::FunctionCallOutput { id: None, - call_id, + call_id: Some(call_id), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text(format!( "result evidence {index}: {}", "signal ".repeat(1_000) diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs index b1bd5718a9..fe15cf93be 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs @@ -228,7 +228,9 @@ impl TranscriptConfig { (format!("tool {name} call"), arguments.clone()) } ResponseItem::FunctionCallOutput { - call_id, output, .. + call_id: Some(call_id), + output, + .. } | ResponseItem::CustomToolCallOutput { call_id, output, .. @@ -287,6 +289,7 @@ impl TranscriptConfig { ("tool web_search call".to_owned(), text) } ResponseItem::AdditionalTools { .. } + | ResponseItem::FunctionCallOutput { call_id: None, .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs index 0da3d5f551..75d5d23628 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs @@ -40,7 +40,9 @@ fn transcript_keeps_conversation_and_configured_sources() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("Workspace inspected.".to_string()), internal_chat_message_metadata_passthrough: None, }, @@ -459,7 +461,9 @@ fn transcript_truncates_tool_results_using_standard_budget() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_owned(), + call_id: Some("call-1".to_owned()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text(output), internal_chat_message_metadata_passthrough: None, }, @@ -596,7 +600,9 @@ fn transcript_omits_media_payloads_and_keeps_readable_content() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { text: "Screenshot captured.".to_string(), diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 7e6672e7cf..966b2719da 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -107,7 +107,9 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "mcp-call".to_string(), + call_id: Some("mcp-call".to_string()), + name: None, + namespace: None, output: image_output("mcp"), internal_chat_message_metadata_passthrough: None, }, @@ -136,7 +138,9 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { }, ResponseItem::FunctionCallOutput { id: None, - call_id: "orphan-call".to_string(), + call_id: Some("orphan-call".to_string()), + name: None, + namespace: None, output: image_output("orphan"), internal_chat_message_metadata_passthrough: None, }, diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index b4f53fbc97..da927d7440 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -511,7 +511,9 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { })); } ResponseItem::FunctionCallOutput { - call_id, output, .. + call_id: Some(call_id), + output, + .. } if function_call_ids.contains(call_id.as_str()) => { image_urls.extend(output_image_urls(output)); } diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 820b59030f..215574cdf0 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -758,7 +758,9 @@ mod tests { job::serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem( ResponseItem::FunctionCallOutput { id: None, - call_id: "call_123".to_string(), + call_id: Some("call_123".to_string()), + name: None, + namespace: None, output: codex_protocol::models::FunctionCallOutputPayload { body: codex_protocol::models::FunctionCallOutputBody::Text( r#"{"token":"sk-abcdefghijklmnopqrstuvwxyz123456"}"#.to_string(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 18b4bee9e8..95ced2fb5d 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1043,7 +1043,15 @@ pub enum ResponseItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] id: Option, - call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + namespace: Option, #[ts(as = "FunctionCallOutputBody")] #[schemars(with = "FunctionCallOutputBody")] output: FunctionCallOutputPayload, @@ -1776,7 +1784,9 @@ impl From for ResponseItem { }, ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput { id: None, - call_id, + call_id: Some(call_id), + name: None, + namespace: None, output, internal_chat_message_metadata_passthrough: None, }, @@ -1784,7 +1794,9 @@ impl From for ResponseItem { let output = output.into_function_call_output_payload(); Self::FunctionCallOutput { id: None, - call_id, + call_id: Some(call_id), + name: None, + namespace: None, output, internal_chat_message_metadata_passthrough: None, } @@ -3099,6 +3111,55 @@ mod tests { ); } + #[test] + fn paired_function_call_output_preserves_existing_wire_shape() { + let value = serde_json::json!({ + "type": "function_call_output", + "call_id": "call-1", + "output": "done", + }); + let item: ResponseItem = serde_json::from_value(value.clone()) + .expect("paired function call output should deserialize"); + + assert_eq!( + item, + ResponseItem::FunctionCallOutput { + id: None, + call_id: Some("call-1".to_string()), + name: None, + namespace: None, + output: FunctionCallOutputPayload::from_text("done".to_string()), + internal_chat_message_metadata_passthrough: None, + } + ); + assert_eq!(serde_json::to_value(item).expect("serialize item"), value); + } + + #[test] + fn named_unpaired_function_call_output_round_trips_without_call_id() { + let value = serde_json::json!({ + "type": "function_call_output", + "name": "notifications", + "namespace": "slack", + "output": "Alice mentioned you.", + }); + let item: ResponseItem = serde_json::from_value(value.clone()) + .expect("named unpaired function call output should deserialize"); + + assert_eq!( + item, + ResponseItem::FunctionCallOutput { + id: None, + call_id: None, + name: Some("notifications".to_string()), + namespace: Some("slack".to_string()), + output: FunctionCallOutputPayload::from_text("Alice mentioned you.".to_string()), + internal_chat_message_metadata_passthrough: None, + } + ); + assert_eq!(serde_json::to_value(item).expect("serialize item"), value); + } + #[test] fn function_call_preserves_empty_encrypted_function_args() { let value = serde_json::json!({ diff --git a/codex-rs/protocol/src/models/executed_tool_calls_tests.rs b/codex-rs/protocol/src/models/executed_tool_calls_tests.rs index 5409b7359a..e541f56b95 100644 --- a/codex-rs/protocol/src/models/executed_tool_calls_tests.rs +++ b/codex-rs/protocol/src/models/executed_tool_calls_tests.rs @@ -29,7 +29,9 @@ fn executed_tool_call_prompt_budget_includes_metadata_fields() -> Result<()> { .map(|index| { let mut item = ResponseItem::FunctionCallOutput { id: None, - call_id: index.to_string(), + call_id: Some(index.to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(String::new()), success: None, @@ -78,7 +80,9 @@ fn executed_tool_call_prompt_budget_includes_metadata_fields() -> Result<()> { .map(|index| { let mut item = ResponseItem::FunctionCallOutput { id: None, - call_id: index.to_string(), + call_id: Some(index.to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(String::new()), success: None, @@ -141,7 +145,9 @@ fn model_arguments_cannot_forge_executed_tool_call_truncation() -> Result<()> { let mut item = ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(String::new()), success: None, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index d3d8209144..2b3488bba0 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1030,7 +1030,9 @@ mod tests { RolloutItem::ResponseItem( ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload::from_text("tool output".to_string()), internal_chat_message_metadata_passthrough: None, } diff --git a/codex-rs/utils/audio/src/audio_preparation_tests.rs b/codex-rs/utils/audio/src/audio_preparation_tests.rs index 9307f8a84e..3a7276996b 100644 --- a/codex-rs/utils/audio/src/audio_preparation_tests.rs +++ b/codex-rs/utils/audio/src/audio_preparation_tests.rs @@ -90,7 +90,9 @@ fn preparation_replaces_invalid_message_audio_with_placeholders() { fn preparation_replaces_only_failed_tool_audio_and_preserves_metadata() { let mut items = vec![ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputText { @@ -114,7 +116,9 @@ fn preparation_replaces_only_failed_tool_audio_and_preserves_metadata() { items, vec![ResponseItem::FunctionCallOutput { id: None, - call_id: "call-1".to_string(), + call_id: Some("call-1".to_string()), + name: None, + namespace: None, output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputText {