Allow deferring plain-text MCP output in thread item listings (#60390)

## What changed

Add experimental `deferOutput.mcpToolCall` support to `thread/items/list` requests scoped to a `turnId`. Successful, completed MCP calls with nonempty, unannotated plain-text results can return empty `result.content` with `outputDeferred: true`. Clients can retrieve the saved output through `thread/items/read`; connector identity remains on the listed item.

Keep rich results, JSON or fenced text, app UI results, and built-in `codex_app` results inline. Preserve plain-text output when creating bounded previews of oversized MCP results so those previews remain eligible for deferral. Reads return the saved preview when output exceeds the 64 KiB history limit.

## Testing

Extend thread history integration coverage for independent and combined command/MCP deferral, experimental capability gating, excluded result types, connector identity, oversized previews, and retrieval of deferred items.

GitOrigin-RevId: 5c160b30a4eac2a9b70e3fa65b6dbd83e935338b
This commit is contained in:
Brent Traut
2026-11-09 12:54:12 +00:00
committed by copyberry
parent e787af9624
commit d2b4d63e95
7 changed files with 307 additions and 64 deletions
@@ -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,
+17 -7
View File
@@ -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)
@@ -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<ThreadItemEntry, JSONRPCErrorError> {
+192 -41
View File
@@ -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<Value> {
async fn list(server: &mut TestAppServer, mut params: Value) -> Result<Value> {
// 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<Vec<RolloutItem>> {
let text = |text: &str| json!({"type": "text", "text": text});
let content = |blocks: Vec<Value>| 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;
+15
View File
@@ -361,6 +361,21 @@ pub struct CallToolResult {
pub meta: Option<serde_json::Value>,
}
/// 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
+22 -1
View File
@@ -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::<Option<Vec<_>>>()
&& 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 {