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 80818eadfb..3ee59785d8 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/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 8c7e5f5666..a5845056db 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1879,6 +1879,10 @@ pub struct ThreadItemOutputDeferral { #[experimental("thread/items/list.deferOutput.commandExecution")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub command_execution: bool, + /// Defer nonempty plain-text results from successful MCP tool calls. + #[experimental("thread/items/list.deferOutput.mcpToolCall")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub mcp_tool_call: bool, } /// Starting position for an item-history page. @@ -1909,7 +1913,7 @@ pub struct ThreadItemEntry { /// Turn containing this item. pub turn_id: String, pub item: ThreadItem, - /// The saved command output was omitted. Fetch the complete item with `thread/items/read`. + /// The saved output was omitted. Fetch the complete item with `thread/items/read`. #[experimental("thread/items/list.outputDeferred")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub output_deferred: bool, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6539b7f6b5..5e9dfd6df8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -755,13 +755,23 @@ Omitted or null cursors preserve normal first-page behavior. Continue anchored pages with the returned opaque string `nextCursor`; response fields and `backwardsCursor` semantics are unchanged. -Experimental clients can set `"deferOutput": {"commandExecution": true}` -with a `turnId`. Omitted or false options keep the full output. Unknown fields in -`deferOutput` are ignored. -For a completed command with a recorded completion time, exit code zero, and -nonempty saved output, the entry returns `outputDeferred: true` and -`item.aggregatedOutput: null`. Use `thread/items/read` with that turn and item ID -to retrieve the full output. All other entries retain their recorded output. +Experimental clients can request `"deferOutput": {"commandExecution": true, "mcpToolCall": true}` +with a `turnId`. Each option applies independently to items with a recorded +completion time and defaults to false. The server ignores unknown options. + +- `commandExecution` returns `item.aggregatedOutput: null` for a successful command with + exit code zero and nonempty saved output. +- `mcpToolCall` returns `item.result.content: []` for a successful tool call with + nonempty output made up entirely of plain, unannotated text blocks. Calls with + an app resource URI or UI, result metadata, structured content, non-text blocks, or + text beginning with a JSON object, array, or Markdown fence retain the full result. + Connector identity stays on the item when its output is deferred. + The built-in `codex_app` server also keeps its results inline because Desktop + renders those calls as native tools, including terminal reads. + +Both forms set `outputDeferred: true`. Use `thread/items/read` with the turn +and item ID to retrieve the saved output. For MCP results over the 64 KiB +history limit, this returns the bounded preview saved at completion. ## Workspace switching (experimental) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 130bb85a9e..b515ad6d16 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -26,6 +26,7 @@ use crate::thread_sample::generate_sample; use codex_app_server_protocol::SelectedCapabilityRoot; use codex_app_server_protocol::ThreadHistoryMode as ApiThreadHistoryMode; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemOutputDeferral; use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListAnchor; use codex_app_server_protocol::ThreadItemsListCursor; @@ -46,6 +47,7 @@ use codex_protocol::SanitizedGitUrl; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErrorDetails; use codex_protocol::mcp::ClientMcpExtensions; +use codex_protocol::mcp::plain_output_text; use codex_protocol::protocol::ThreadHistoryMode; use codex_thread_store::LocalHistoryAccount; use codex_thread_store::PersistContext; @@ -3733,8 +3735,7 @@ impl ThreadRequestProcessor { sort_direction, defer_output, } = params; - let defer_command_output = - turn_id.is_some() && defer_output.is_some_and(|options| options.command_execution); + let defer_output = defer_output.filter(|_| turn_id.is_some()); let position = cursor.map(|cursor| match cursor { ThreadItemsListCursor::Opaque(cursor) => StoreListItemsPosition::Cursor(cursor), ThreadItemsListCursor::Anchor(ThreadItemsListAnchor::Item { item_id }) => { @@ -3787,20 +3788,10 @@ impl ThreadRequestProcessor { let mut data = Vec::with_capacity(page.items.len()); for stored_item in page.items { let mut entry = stored_thread_item_to_entry(stored_item)?; - if defer_command_output + if let Some(options) = &defer_output && entry.completed_at_ms.is_some() - && let ThreadItemPayload::CommandExecution { - status: codex_app_server_protocol::CommandExecutionStatus::Completed, - exit_code: Some(0), - aggregated_output, - .. - } = &mut entry.item.payload - && aggregated_output - .as_ref() - .is_some_and(|output| !output.is_empty()) { - *aggregated_output = None; - entry.output_deferred = true; + entry.output_deferred = defer_item_output(&mut entry.item.payload, options); } data.push(entry); } @@ -6492,6 +6483,57 @@ fn stored_thread_item_to_api_item( }) } +fn defer_item_output(item: &mut ThreadItemPayload, options: &ThreadItemOutputDeferral) -> bool { + match item { + ThreadItemPayload::CommandExecution { + status: codex_app_server_protocol::CommandExecutionStatus::Completed, + exit_code: Some(0), + aggregated_output, + .. + } if options.command_execution + && aggregated_output + .as_ref() + .is_some_and(|output| !output.is_empty()) => + { + *aggregated_output = None; + } + ThreadItemPayload::McpToolCall { + server, + status: codex_app_server_protocol::McpToolCallStatus::Completed, + result: Some(result), + error: None, + app_context, + mcp_app_resource_uri: None, + mcp_app_ui: None, + .. + } if options.mcp_tool_call + && server != "codex_app" + && app_context + .as_ref() + .is_none_or(|ctx| ctx.resource_uri.is_none()) + && is_deferrable_mcp_result(result) => + { + result.content.clear(); + } + _ => return false, + } + true +} + +fn is_deferrable_mcp_result(result: &codex_app_server_protocol::McpToolCallResult) -> bool { + if result.structured_content.is_some() || result.meta.is_some() { + return false; + } + let mut has_text = false; + for block in &result.content { + let Some(text) = plain_output_text(block) else { + return false; + }; + has_text |= !text.is_empty(); + } + has_text +} + fn stored_thread_item_to_entry( stored_item: codex_thread_store::StoredThreadItem, ) -> Result { diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 85844f5cda..4f117dc2e2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1835,13 +1835,15 @@ async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> } #[tokio::test] -async fn thread_items_list_defers_successful_command_output() -> Result<()> { +async fn thread_items_list_defers_requested_outputs() -> Result<()> { use codex_protocol::items::CommandExecutionStatus::Completed; use codex_protocol::items::CommandExecutionStatus::Declined; use codex_protocol::items::CommandExecutionStatus::Failed; use codex_protocol::items::CommandExecutionStatus::InProgress; - async fn list(server: &mut TestAppServer, params: Value) -> Result { + async fn list(server: &mut TestAppServer, mut params: Value) -> Result { + // Compare all fixture items on one page. + params["limit"] = json!(100); let id = server .send_request("thread/items/list", Some(params)) .await?; @@ -1891,6 +1893,9 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> { }), )); } + let mcp_items = mcp_output_deferral_items(thread_id)?; + let expected_count = commands.len() + mcp_items.len(); + items.extend(mcp_items); store .append_items(AppendThreadItemsParams { thread_id, items }) .await?; @@ -1914,7 +1919,28 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> { let mut params = json!({"threadId": thread_id, "turnId": "turn"}); let full = list(&mut stable, params.clone()).await?; let entries = full["data"].as_array().expect("list entries"); - assert_eq!(entries.len(), commands.len()); + assert_eq!(entries.len(), expected_count); + let mcp_index = commands.len(); + assert_eq!(entries[mcp_index]["item"]["id"], "large-mcp"); + let saved = &entries[mcp_index]["item"]["result"]; + let preview = saved["content"][0]["text"].as_str().expect("saved preview"); + assert!(preview.contains("truncated")); + assert!(preview.contains("end of plain output")); + assert!(preview.len() < 64 * 1024); + let connector = entries + .iter() + .find(|entry| entry["item"]["id"] == "connector-mcp") + .expect("hosted connector"); + assert_eq!( + connector["item"]["appContext"]["connectorId"], + "fixture-connector" + ); + let empty = entries + .iter() + .find(|entry| entry["item"]["id"] == "empty-result") + .expect("empty MCP result"); + assert_eq!(empty["item"]["result"]["content"], json!([])); + assert!(empty.get("outputDeferred").is_none()); for (entry, (id, _, _, output)) in entries.iter().zip(commands) { assert_eq!(entry["item"]["id"], id); assert_eq!( @@ -1927,23 +1953,25 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> { for options in [ Value::Null, json!({}), - json!({"commandExecution": false}), + json!({"commandExecution": false, "mcpToolCall": false}), json!({"unknownItemKind": true}), ] { params["deferOutput"] = options; assert_eq!(list(&mut stable, params.clone()).await?, full); } - params["deferOutput"] = json!({"commandExecution": true, "unknownItemKind": true}); - let id = stable - .send_request("thread/items/list", Some(params.clone())) - .await?; - let error = timeout( - DEFAULT_READ_TIMEOUT, - stable.read_stream_until_error_message(RequestId::Integer(id)), - ) - .await??; - assert_eq!(error.error.code, -32600); + for option in ["commandExecution", "mcpToolCall"] { + params["deferOutput"] = json!({(option): true}); + let id = stable + .send_request("thread/items/list", Some(params.clone())) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + stable.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert_eq!(error.error.code, -32600); + } let mut experimental = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -1958,43 +1986,166 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> { .await?, full ); - let deferred = list(&mut experimental, params).await?; - let mut expected = full.clone(); - for i in 0..2 { - expected["data"][i]["outputDeferred"] = json!(true); - expected["data"][i]["item"]["aggregatedOutput"] = Value::Null; - } - assert_eq!(deferred, expected); + for (commands, mcp) in [(true, false), (false, true), (true, true)] { + params["deferOutput"] = + json!({"commandExecution": commands, "mcpToolCall": mcp, "unknownItemKind": true}); + let mut expected = full.clone(); + let mut originals = Vec::new(); + if commands { + for (i, original) in entries.iter().take(2).enumerate() { + originals.push(original); + expected["data"][i]["outputDeferred"] = json!(true); + expected["data"][i]["item"]["aggregatedOutput"] = Value::Null; + } + } + if mcp { + for (entry, original) in expected["data"] + .as_array_mut() + .expect("list entries") + .iter_mut() + .zip(entries) + { + if matches!( + entry["item"]["id"].as_str(), + Some("large-mcp" | "connector-mcp" | "small-mcp") + ) { + entry["outputDeferred"] = json!(true); + entry["item"]["result"]["content"] = json!([]); + originals.push(original); + } + } + } + let response = list(&mut experimental, params.clone()).await?; + assert_eq!(response, expected); - let projected: ThreadItemsListResponse = serde_json::from_value(deferred)?; - let mut entries_to_fetch = projected - .data - .into_iter() - .filter_map(|entry| ThreadItem::try_from(entry).err()); - let large_command = entries_to_fetch.next().expect("large command needs a read"); - let small_command = entries_to_fetch.next().expect("small command needs a read"); - assert!(entries_to_fetch.next().is_none()); + let page: ThreadItemsListResponse = serde_json::from_value(response)?; + let deferred: Vec<_> = page + .data + .into_iter() + .filter_map(|entry| ThreadItem::try_from(entry).err()) + .collect(); + let exact: Value = experimental + .request(|request_id| ClientRequest::ThreadItemsRead { + request_id, + params: ThreadItemsReadParams { + thread_id: thread_id.to_string(), + turn_id: deferred + .first() + .expect("each opt-in defers an item") + .turn_id + .clone(), + item_ids: deferred.into_iter().map(|item| item.item_id).collect(), + }, + }) + .await?; + assert_eq!(exact, json!({"data": originals})); + } let across_turns = list( &mut experimental, - json!({"threadId": thread_id, "deferOutput": {"commandExecution": true}}), + json!({"threadId": thread_id, "deferOutput": {"commandExecution": true, "mcpToolCall": true}}), ) .await?; assert_eq!(across_turns["data"], full["data"]); - let exact: Value = experimental - .request(|request_id| ClientRequest::ThreadItemsRead { - request_id, - params: ThreadItemsReadParams { - thread_id: thread_id.to_string(), - turn_id: small_command.turn_id, - item_ids: vec![large_command.item_id, small_command.item_id], - }, - }) - .await?; - assert_eq!(exact, json!({"data": [entries[0], entries[1]]})); Ok(()) } +fn mcp_output_deferral_items(thread_id: codex_protocol::ThreadId) -> Result> { + let text = |text: &str| json!({"type": "text", "text": text}); + let content = |blocks: Vec| json!({"result": {"content": blocks}}); + let oversized = "é".repeat(40 * 1024); + let large = "x".repeat(4096); + let plain = || text(&large); + let notion = json!({"results": [{"object": "page", "url": "https://www.notion.so/page", "title": large}]}).to_string(); + let cases = [ + ( + "large-mcp", + content(vec![text(&oversized), text("end of plain output")]), + ), + ( + "connector-mcp", + json!({"server": "codex_apps", "connectorId": "fixture-connector", "linkId": "link-1"}), + ), + ( + "builtin-terminal", + json!({"server": "codex_app", "tool": "read_thread_terminal"}), + ), + ("small-mcp", content(vec![text("x"), text("")])), + ("empty-text", content(vec![text("")])), + ("empty-result", content(vec![])), + ( + "failed-mcp", + json!({"status": "failed", "error": {"message": "tool failed"}}), + ), + ("running-mcp", json!({"status": "inProgress"})), + ("missing-result", json!({"result": null})), + ( + "annotated", + content(vec![ + plain(), + json!({"type": "text", "text": "caption", "annotations": {"audience": ["user"]}}), + ]), + ), + ( + "image-result", + content(vec![ + plain(), + json!({"type": "image", "mimeType": "image/png", "data": "YQ=="}), + ]), + ), + ( + "structured-result", + json!({"result": {"content": [plain()], "structuredContent": {"title": "Source"}}}), + ), + ( + "metadata-result", + json!({"result": {"content": [plain()], "_meta": {"uri": "ui://result"}}}), + ), + ( + "app-resource", + json!({"server": "codex_apps", "connectorId": "fixture-connector", "mcpAppResourceUri": "ui://panel"}), + ), + ("legacy-app-ui", json!({"mcpAppResourceUri": "ui://panel"})), + ( + "app-ui", + json!({"mcpAppUi": {"resourceUri": "ui://panel", "preferredModelDisplayMode": "inline"}}), + ), + ( + "json-result", + content(vec![text(&format!("\u{feff} {notion}"))]), + ), + ( + "mixed-json", + content(vec![text(&oversized), text(" \n []")]), + ), + ( + "fenced-result", + content(vec![plain(), text(" \n ```json\n{}\n```")]), + ), + ( + "tilde-fence", + content(vec![plain(), text("~~~json\n{}\n~~~")]), + ), + ]; + let mut items = Vec::new(); + for (id, overrides) in cases { + let mut call = json!({ + "id": id, "server": "fixture", "tool": "lookup", "status": "completed", + "arguments": {"query": "saved"}, "pluginId": "fixture@local", "readOnlyHint": true, + "result": {"content": [plain()]}, + }); + for (field, value) in overrides.as_object().expect("fixture overrides") { + call[field] = value.clone(); + } + items.push(paginated_completed_item( + thread_id, + "turn", + CoreTurnItem::McpToolCall(serde_json::from_value(call)?), + )); + } + Ok(items) +} + #[tokio::test] async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index 1a409c4931..a2287d8915 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -361,6 +361,21 @@ pub struct CallToolResult { pub meta: Option, } +/// Returns display text from an unannotated MCP text block. Some tools encode +/// sources and UI data as JSON or fenced text, which history must keep inline. +pub fn plain_output_text(block: &serde_json::Value) -> Option<&str> { + let fields = block.as_object()?; + if fields.len() != 2 || fields.get("type")?.as_str()? != "text" { + return None; + } + let text = fields.get("text")?.as_str()?; + let trimmed = text.trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}'); + if trimmed.starts_with(['{', '[']) || trimmed.starts_with("```") || trimmed.starts_with("~~~") { + return None; + } + Some(text) +} + // === Adapter helpers === // // These types and conversions intentionally live in `codex-protocol` so other crates can convert diff --git a/codex-rs/utils/output-truncation/src/lib.rs b/codex-rs/utils/output-truncation/src/lib.rs index c0a9fbdc58..6f313059c8 100644 --- a/codex-rs/utils/output-truncation/src/lib.rs +++ b/codex-rs/utils/output-truncation/src/lib.rs @@ -3,6 +3,7 @@ use std::borrow::Cow; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::plain_output_text; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -72,7 +73,7 @@ pub fn truncate_mcp_tool_result( result: &CallToolResult, max_bytes: usize, ) -> Cow<'_, CallToolResult> { - let Ok(serialized) = serde_json::to_string(result) else { + let Ok(mut serialized) = serde_json::to_string(result) else { return Cow::Borrowed(result); }; if serialized.len() <= max_bytes { @@ -81,6 +82,26 @@ pub fn truncate_mcp_tool_result( let meta = None; + // Keep plain output recognizable after truncation. Rich results retain their + // serialized preview so history can distinguish them from plain output. + if result.is_error != Some(true) + && result.structured_content.is_none() + && result.meta.is_none() + && let Some(blocks) = result + .content + .iter() + .map(plain_output_text) + .collect::>>() + && blocks.iter().any(|text| !text.is_empty()) + { + serialized.clear(); + serialized.push_str("Truncated tool output:"); + for text in blocks { + serialized.push('\n'); + serialized.push_str(text); + } + } + let mut preview_budget = max_bytes; loop { let preview = if preview_budget == 0 {