mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user