Preserve full MCP results in rollout traces (#57280)

## Why

MCP completion events can truncate large results and omit content removed for model consumption. Rollout traces need the original response, including content and metadata, as well as results for calls denied before reaching the backend.

## What changed

- Record MCP runtime results before callbacks, content filtering, and UI truncation, and record skipped calls as failures.
- Stop deriving MCP runtime completion records from protocol events to avoid duplicate records.
- Treat MCP runtime payloads as opaque results when reducing agent interactions.
- Buffer JSON trace writes and explicitly flush them.

## Testing

Extend app-server tests to verify that traces retain oversized MCP text, audio, structured content, and metadata while UI results remain truncated, and that denied calls produce exactly one failed runtime result with the denial reason.

GitOrigin-RevId: 57880770545eb5869420d7dc6fa4a15e481cae9d
This commit is contained in:
cassirer-openai
2026-10-26 21:56:23 +00:00
committed by copyberry
parent 6d8f311b00
commit f2df3e3b56
7 changed files with 154 additions and 23 deletions
@@ -1003,9 +1003,15 @@ async fn guardian_v2_routes_scoped_tool_approvals(
Some(thread_id)
}
};
let traces = TempDir::new()?;
let trace_root = matches!(review_outcome, ReviewOutcome::Deny)
.then(|| traces.path().to_str().expect("trace root path"));
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OTEL_METRIC_EXPORT_INTERVAL", Some("25"))])
.with_env_overrides(&[
("OTEL_METRIC_EXPORT_INTERVAL", Some("25")),
("CODEX_ROLLOUT_TRACE_ROOT", trace_root),
])
.build_initialized_with_timeout(TIMEOUT)
.await?;
let thread = match lifecycle {
@@ -2066,6 +2072,37 @@ async fn guardian_v2_routes_scoped_tool_approvals(
})
.await?;
timeout(TIMEOUT, app_server.shutdown_gracefully()).await??;
if matches!(review_outcome, ReviewOutcome::Deny) {
// Denied calls have no backend response, but their runtime result must
// still be available to trace consumers alongside executed calls.
let mut denied_results = Vec::new();
for entry in std::fs::read_dir(traces.path())? {
let bundle = entry?.path();
for line in std::fs::read_to_string(bundle.join("trace.jsonl"))?.lines() {
let event: Value = serde_json::from_str(line)?;
let payload = &event["payload"];
if event["thread_id"] == reviewed_thread_id
&& payload["type"] == "tool_call_runtime_ended"
&& payload["tool_call_id"] == "guardian-action-0"
{
assert_eq!(payload["status"], "failed");
let path = payload["runtime_payload"]["path"]
.as_str()
.expect("MCP runtime payload path");
let result: Value =
serde_json::from_reader(std::fs::File::open(bundle.join(path))?)?;
denied_results.push(result);
}
}
}
assert_eq!(denied_results.len(), 1);
assert!(
denied_results[0]["result"]["Err"]
.as_str()
.expect("denied MCP result")
.contains("The destination is not authorized.")
);
}
let reviewer_id = responses_state
.guardian_requests
.lock()
+47 -1
View File
@@ -999,6 +999,7 @@ async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> {
let traces = TempDir::new()?;
let call_id = "call-large-mcp";
let namespace = format!("mcp__{TEST_SERVER_NAME}");
let responses = vec![
@@ -1028,6 +1029,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result()
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("CODEX_ROLLOUT_TRACE_ROOT", traces.path().to_str())])
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
@@ -1105,6 +1107,47 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result()
)
.await??;
assert!(mcp.shutdown_gracefully().await?.success());
// The UI receives a truncated result, but the trace retains the original
// content and metadata, including audio removed during model sanitization.
let bundles = std::fs::read_dir(traces.path())?.collect::<std::io::Result<Vec<_>>>()?;
assert_eq!(bundles.len(), 1);
let bundle = bundles[0].path();
let events = std::fs::read_to_string(bundle.join("trace.jsonl"))?
.lines()
.map(serde_json::from_str::<serde_json::Value>)
.collect::<serde_json::Result<Vec<_>>>()?;
let completions: Vec<_> = events
.iter()
.map(|event| &event["payload"])
.filter(|event| {
event["type"] == "tool_call_runtime_ended" && event["tool_call_id"] == call_id
})
.collect();
assert_eq!(completions.len(), 1);
let payload_path = completions[0]["runtime_payload"]["path"]
.as_str()
.expect("MCP runtime payload path");
let payload: serde_json::Value =
serde_json::from_reader(std::fs::File::open(bundle.join(payload_path))?)?;
let expected = json!({
"content": [
{"type": "text", "text": "large-mcp-content-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8)},
{"type": "audio", "data": "UklGRg==", "mimeType": "audio/wav"}
],
"structuredContent": {"large": "structured-value-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8)},
"isError": false,
"_meta": {
"calledBy": "mcp-app",
}
});
// Avoid constructing a text diff of the intentionally oversized response.
assert!(
payload["result"]["Ok"] == expected,
"trace did not retain the full MCP response"
);
mcp_server_handle.abort();
let _ = mcp_server_handle.await;
@@ -1443,7 +1486,10 @@ impl ServerHandler for ToolAppsMcpServer {
let mut result = CallToolResult::structured(json!({
"large": "structured-value-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8),
}));
result.content = vec![ContentBlock::text(large_text)];
result.content = vec![
ContentBlock::text(large_text),
ContentBlock::audio("UklGRg==", "audio/wav"),
];
result.meta = Some(meta);
return Ok(result.into());
}