Track cumulative MCP attribution across requests and thread history (#47081)

## What changed

- Record each MCP source and its first turn, including connector and plugin identifiers when available. Cover direct and Code Mode calls, including returned errors and results that are not printed.
- Persist cumulative attribution checkpoints and restore them across compaction, resume, and forks, including forks limited to recent turns.
- Attach fresh attribution to Responses API `client_metadata` under `mcp_attribution`, outside model-visible content. Restrict transmission to allowed OpenAI HTTPS destinations for both HTTP and WebSocket requests.
- Report `attribution_error` for legacy history without checkpoints, malformed or conflicting attribution, and serialized attribution exceeding 16 KiB.

## Testing

Add coverage for source deduplication, checkpoint persistence and restoration, recent-turn forks, direct and Code Mode calls, destination filtering, and the serialized size limit.

GitOrigin-RevId: fd285e152ca9e24bfa000debb20013709d67a86e
This commit is contained in:
peilin-openai
2026-09-21 18:19:07 +00:00
committed by copyberry
parent 7cec3e110c
commit ac98537678
28 changed files with 1119 additions and 73 deletions
+185 -4
View File
@@ -22,7 +22,10 @@ use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::context::SubagentNotification;
use crate::init_state_db;
use crate::responses_metadata::CodexResponsesRequestKind;
use crate::session::SessionSettingsUpdate;
use crate::session::step_context::StepContext;
use crate::thread_manager::ForkSnapshot;
use crate::thread_manager::StartThreadOptions;
use crate::tools::handlers::multi_agents_common::thread_spawn_source;
use assert_matches::assert_matches;
@@ -46,11 +49,15 @@ use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Settings;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::McpAttribution;
use codex_protocol::mcp::McpAttributionSource;
use codex_protocol::mcp::McpAttributionStatus;
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
@@ -425,6 +432,48 @@ async fn persist_thread_for_tree_resume(thread: &Arc<CodexThread>, message: &str
.expect("test thread rollout should flush");
}
/// Builds a request entirely in process; it does not install a transport or send anything.
/// The destination filtering behavior is covered separately in `client_tests`.
async fn mcp_attribution_in_constructed_request(thread: &CodexThread) -> McpAttribution {
let turn_context = thread.session.new_default_turn().await;
let step_context = StepContext::for_test(Arc::clone(&turn_context));
let prompt = crate::session::turn::build_prompt(
thread
.session
.clone_history()
.await
.for_prompt(&step_context.settings.model_info.input_modalities),
&step_context,
thread.session.get_prompt_base_instructions().await,
);
let metadata = thread
.session
.responses_metadata(&step_context, CodexResponsesRequestKind::Turn)
.await;
let request = thread
.session
.services
.model_client
.build_responses_request(
&prompt,
&step_context.settings.model_info,
/*effort*/ None,
ReasoningSummary::None,
/*service_tier*/ None,
&metadata,
/*include_internal*/ true,
)
.expect("construct request");
serde_json::from_str(
request
.client_metadata
.as_ref()
.and_then(|metadata| metadata.get("mcp_attribution"))
.expect("request MCP attribution"),
)
.expect("valid request MCP attribution")
}
async fn wait_for_live_thread_spawn_children(
control: &LocalAgentControl,
parent_thread_id: ThreadId,
@@ -3009,6 +3058,103 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
}
}
#[tokio::test]
async fn resume_and_cold_fork_restore_mcp_attribution_in_constructed_requests() {
let harness = AgentControlHarness::new().await;
let (source_thread_id, source_thread) = harness.start_thread().await;
let turn_context = source_thread.session.new_default_turn().await;
source_thread
.session
.record_conversation_items(
turn_context.as_ref(),
turn_context.model_info(),
&[user_message("source turn")],
)
.await;
let source = McpAttributionSource {
connector_id: None,
plugin_id: Some("messages@openai-bundled".to_string()),
server_name: "messages".to_string(),
tool_name: "search".to_string(),
first_turn_id: turn_context.sub_id.clone(),
};
source_thread
.session
.services
.executed_tool_calls
.record_mcp_source(source.clone());
source_thread
.session
.record_conversation_items(
turn_context.as_ref(),
turn_context.model_info(),
&[assistant_message("source result", /*phase*/ None)],
)
.await;
source_thread.ensure_rollout_materialized().await;
source_thread
.flush_rollout()
.await
.expect("flush source rollout");
let rollout_path = source_thread.rollout_path().expect("source rollout path");
source_thread
.shutdown_and_wait()
.await
.expect("shutdown source thread");
assert!(
harness
.manager
.remove_thread(&source_thread_id)
.await
.is_some()
);
drop(source_thread);
let forked = harness
.manager
.fork_thread(
ForkSnapshot::Interrupted,
StartThreadOptions::new(harness.config.clone()),
rollout_path.clone(),
)
.await
.expect("fork unloaded source thread");
let resumed = harness
.manager
.resume_thread_from_rollout(
harness.config.clone(),
rollout_path,
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
/*parent_trace*/ None,
ClientMcpExtensions::default(),
)
.await
.expect("resume source thread");
let expected = McpAttribution {
status: McpAttributionStatus::Complete,
sources: vec![source],
};
assert_eq!(
mcp_attribution_in_constructed_request(&forked.thread).await,
expected
);
assert_eq!(
mcp_attribution_in_constructed_request(&resumed.thread).await,
expected
);
forked
.thread
.shutdown_and_wait()
.await
.expect("shutdown forked thread");
resumed
.thread
.shutdown_and_wait()
.await
.expect("shutdown resumed thread");
}
#[tokio::test]
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
let harness = AgentControlHarness::new().await;
@@ -3075,11 +3221,35 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let old_turn_context = parent_thread.session.new_default_turn().await;
parent_thread
.inject_response_items(vec![user_message("old parent context")])
.await
.expect("inject old parent context");
.session
.record_conversation_items(
old_turn_context.as_ref(),
old_turn_context.model_info(),
&[user_message("old parent context")],
)
.await;
let source = McpAttributionSource {
connector_id: None,
plugin_id: None,
server_name: "example".to_string(),
tool_name: "search".to_string(),
first_turn_id: old_turn_context.sub_id.clone(),
};
parent_thread
.session
.services
.executed_tool_calls
.record_mcp_source(source.clone());
parent_thread
.session
.record_conversation_items(
old_turn_context.as_ref(),
old_turn_context.model_info(),
&[assistant_message("old MCP result", /*phase*/ None)],
)
.await;
let queued_communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
@@ -3176,6 +3346,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
!history_contains_text(history.raw_items(), "old parent context"),
"forked child history should drop parent context outside the requested last-N turn window"
);
assert!(
!history_contains_text(history.raw_items(), "old MCP result"),
"forked child history should drop the old result while retaining its attribution"
);
assert!(
!history_contains_text(history.raw_items(), "queued message"),
"forked child history should drop queued inter-agent messages outside the requested last-N turn window"
@@ -3196,6 +3370,13 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
.is_none(),
"last-N forked child should rebuild context after truncating the cached prefix"
);
assert_eq!(
mcp_attribution_in_constructed_request(&child_thread).await,
McpAttribution {
status: McpAttributionStatus::Complete,
sources: vec![source],
}
);
let _ = harness
.control