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
+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;