From 2fc4bda3ca3e93b764dd3845cb8c1a15a10866e1 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 11 Sep 2026 16:49:41 +0000 Subject: [PATCH] Preserve originating context for yielded code-mode tool calls (#44866) ## Why A code-mode cell can outlive its turn. Nested tool calls dispatched during a later turn need the original step's tools and context, while approval handling must still work across that turn boundary. ## What changed - Retain each cell's originating `StepContext` for nested tool dispatch and cancel queued calls whose context has been released. - Keep MCP approval metadata available for the lifetime of the running invocation, keyed by server and call ID. - Apply Guardian denial accounting and interruption to the active turn, including reviews from an earlier cell. - Carry `turn_id` in legacy MCP begin/end events so history reconstruction and rollout migration assign late completions to their original turn. Preserve the current-turn fallback for older records without it. ## Testing Add regression coverage for originating tool selection across turns, callback and metadata cleanup, delayed MCP elicitation review, Guardian interruption of the active turn, and late MCP completion attribution with legacy compatibility. GitOrigin-RevId: 4533379aae6e4ecb4aabe5a25e4aca5d65385d76 --- .../app-server-exports-stable.json.zst | Bin 150137 -> 150175 bytes .../src/protocol/thread_history.rs | 107 ++++++++++- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/guardian/decision.rs | 2 +- codex-rs/core/src/guardian/review.rs | 29 ++- codex-rs/core/src/guardian/review_request.rs | 6 +- codex-rs/core/src/mcp_tool_call.rs | 50 +++--- codex-rs/core/src/mcp_tool_call_tests.rs | 60 +++++++ codex-rs/core/src/session/mcp.rs | 10 +- codex-rs/core/src/session/session.rs | 10 +- codex-rs/core/src/session/tests.rs | 15 +- codex-rs/core/src/state/turn.rs | 21 --- codex-rs/core/src/tools/code_mode/delegate.rs | 59 +++++-- .../src/tools/code_mode/delegate_tests.rs | 77 +++++++- .../src/tools/code_mode/execute_handler.rs | 19 +- codex-rs/core/src/tools/code_mode/mod.rs | 32 ++-- codex-rs/core/src/tools/parallel.rs | 17 +- codex-rs/core/tests/suite/code_mode.rs | 101 ++++++++++- .../tests/suite/guardian_mcp_elicitation.rs | 150 ++++++++++++++++ codex-rs/core/tests/suite/guardian_review.rs | 167 ++++++++++++++++++ codex-rs/core/tests/suite/rmcp_client.rs | 8 + codex-rs/protocol/src/legacy_events.rs | 17 +- codex-rs/protocol/src/protocol.rs | 8 + .../local/rollout_migration/legacy_event.rs | 2 +- .../src/local/rollout_migration_tests.rs | 17 +- 25 files changed, 838 insertions(+), 147 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 88f160cc9eb412fe25103dfeb8e82a03fd2c0fc4..b80ae236371b32c1805def66be7d8cf3f29a26ac 100644 GIT binary patch delta 19408 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 b2xpb$jU|((b**zNw+5kS65`C?%Rg5O92Y!G delta 19393 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 M?WmhFy?kNO0#R3U5dZ)H diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 75dacd5dc8..44ebe0d62b 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -788,7 +788,11 @@ impl ThreadHistoryBuilder { error: None, duration_ms: None, }; - self.upsert_item_in_current_turn(item); + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } } fn handle_mcp_tool_call_end(&mut self, payload: &McpToolCallEndEvent) { @@ -841,7 +845,11 @@ impl ThreadHistoryBuilder { error, duration_ms, }; - self.upsert_item_in_current_turn(item); + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } } fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) { @@ -2998,6 +3006,7 @@ mod tests { status: CoreExecCommandStatus::Completed, }), EventMsg::McpToolCallEnd(McpToolCallEndEvent { + turn_id: String::new(), call_id: "mcp-1".into(), invocation: McpInvocation { server: "docs".into(), @@ -3091,6 +3100,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::McpToolCallEnd(McpToolCallEndEvent { + turn_id: String::new(), call_id: "mcp-1".into(), invocation: McpInvocation { server: "docs".into(), @@ -3508,6 +3518,99 @@ mod tests { ); } + #[test] + fn assigns_late_legacy_mcp_completion_to_original_turn() { + use codex_protocol::items::McpToolCallItem; + use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; + use codex_protocol::protocol::HasLegacyEvent; + + let completion = ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-a".into(), + started_at_ms: None, + completed_at_ms: 0, + item: CoreTurnItem::McpToolCall(McpToolCallItem { + id: "mcp-1".into(), + server: "slack".into(), + tool: "search".into(), + arguments: serde_json::json!({"query": "incident"}), + connector_id: None, + mcp_app_resource_uri: None, + link_id: None, + app_name: None, + action_name: None, + plugin_id: None, + read_only_hint: Some(true), + status: CoreMcpToolCallStatus::Completed, + result: Some(CallToolResult { + content: vec![serde_json::json!({"type": "text", "text": "Found incident"})], + structured_content: None, + is_error: None, + meta: None, + }), + error: None, + duration: Some(Duration::from_millis(8)), + }), + }; + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + root_turn_id: None, + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "Find related incident discussions".into(), + ..Default::default() + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + started_at: None, + last_agent_message: None, + error: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-b".into(), + root_turn_id: None, + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "Explain retry logic while that runs".into(), + ..Default::default() + }), + ]; + let mut items: Vec<_> = events.into_iter().map(RolloutItem::EventMsg).collect(); + let mut expected = build_turns_from_rollout_items(&items); + expected[0] + .items + .push(ThreadItem::from(completion.item.clone())); + + let legacy = completion.as_legacy_events(/*show_raw_agent_reasoning*/ false); + let serialized = serde_json::to_string(&legacy[0]).unwrap(); + items.push(RolloutItem::EventMsg( + serde_json::from_str(&serialized).unwrap(), + )); + assert_eq!(build_turns_from_rollout_items(&items), expected); + + // Old records have no owner, so retain their current-turn fallback. + let mut old_record: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + old_record.as_object_mut().unwrap().remove("turn_id"); + *items.last_mut().unwrap() = + RolloutItem::EventMsg(serde_json::from_value(old_record).unwrap()); + let mut expected_old = expected; + let mcp_item = expected_old[0].items.pop().unwrap(); + expected_old[1].items.push(mcp_item); + assert_eq!(build_turns_from_rollout_items(&items), expected_old); + } + #[test] fn assigns_late_exec_completion_to_original_turn() { let events = vec![ diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 69da60d821..ba2133a2b5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2678,6 +2678,7 @@ fn append_resume_redaction_history( let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?; let appended_rollout = [ EventMsg::McpToolCallEnd(McpToolCallEndEvent { + turn_id: String::new(), call_id: "mcp-1".to_string(), invocation: McpInvocation { server: "docs".to_string(), diff --git a/codex-rs/core/src/guardian/decision.rs b/codex-rs/core/src/guardian/decision.rs index 6709fb2e67..a1d0026317 100644 --- a/codex-rs/core/src/guardian/decision.rs +++ b/codex-rs/core/src/guardian/decision.rs @@ -177,7 +177,7 @@ pub(crate) async fn decide_approval( }, }); } - record_guardian_non_denial(&session, turn_id).await; + record_guardian_non_denial(&session).await; Some(ReviewDecision::Approved) } Some(ApprovalDecision::Allow) => runtime.review(GuardianReviewReason::FreshRequired).await, diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 641b6cc175..ff2a94763a 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -136,13 +136,28 @@ pub(crate) fn is_basic_session_source(session_source: &SessionSource) -> bool { } } -pub(super) async fn record_guardian_non_denial(session: &Arc, turn_id: &str) { +pub(super) async fn record_guardian_non_denial(session: &Arc) { + let turn_id = { + let active = session.active_turn.lock().await; + let Some(task) = active.as_ref().and_then(|active| active.task.as_ref()) else { + return; + }; + task.turn_context.sub_id.clone() + }; codex_guardian_reviewer::ReviewDenials::for_thread(&session.services.thread_extension_data) - .record_non_denial(turn_id) + .record_non_denial(&turn_id) .await; } -async fn record_guardian_denial(session: &Arc, turn: &Arc, turn_id: &str) { +async fn record_guardian_denial(session: &Arc) { + let turn = { + let active = session.active_turn.lock().await; + let Some(task) = active.as_ref().and_then(|active| active.task.as_ref()) else { + return; + }; + Arc::clone(&task.turn_context) + }; + let turn_id = &turn.sub_id; let Some(message) = codex_guardian_reviewer::ReviewDenials::for_thread(&session.services.thread_extension_data) .record_denial(turn_id, turn.model_info()) @@ -180,12 +195,8 @@ async fn record_guardian_denial(session: &Arc, turn: &Arc, } #[cfg(test)] -pub(crate) async fn record_guardian_denial_for_test( - session: &Arc, - turn: &Arc, - turn_id: &str, -) { - record_guardian_denial(session, turn, turn_id).await; +pub(crate) async fn record_guardian_denial_for_test(session: &Arc) { + record_guardian_denial(session).await; } #[derive(Clone)] diff --git a/codex-rs/core/src/guardian/review_request.rs b/codex-rs/core/src/guardian/review_request.rs index 7683c8834f..5e117b2342 100644 --- a/codex-rs/core/src/guardian/review_request.rs +++ b/codex-rs/core/src/guardian/review_request.rs @@ -129,7 +129,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime { session .send_event(turn.as_ref(), EventMsg::GuardianAssessment(completed.event)) .await; - record_guardian_non_denial(&session, report.turn_id()).await; + record_guardian_non_denial(&session).await; return Err(ReviewDecision::Abort); } @@ -259,9 +259,9 @@ impl ReviewHost for super::super::runtime::ReviewRuntime { .send_event(turn.as_ref(), EventMsg::GuardianAssessment(completed.event)) .await; if completed.assessment_outcome == Some(GuardianAssessmentOutcome::Deny) { - record_guardian_denial(&session, &turn, report.turn_id()).await; + record_guardian_denial(&session).await; } else { - record_guardian_non_denial(&session, report.turn_id()).await; + record_guardian_non_denial(&session).await; } completed.decision } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8b57655124..50244fd34b 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -253,8 +253,8 @@ pub(crate) async fn handle_mcp_tool_call( .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())), }; } - sess.register_mcp_tool_approval_metadata(turn_context, &call_id, &invocation, metadata.clone()) - .await; + let _approval_metadata = + sess.register_mcp_tool_approval_metadata(&call_id, &invocation, metadata.clone()); notify_mcp_tool_call_started( sess.as_ref(), turn_context.as_ref(), @@ -1139,40 +1139,41 @@ pub(crate) struct McpToolApprovalMetadata { } impl Session { - async fn register_mcp_tool_approval_metadata( + fn register_mcp_tool_approval_metadata( &self, - turn_context: &TurnContext, call_id: &str, invocation: &McpInvocation, metadata: McpToolApprovalMetadata, - ) { - let Some(turn_state) = self - .input_queue - .turn_state_for_sub_id(&self.active_turn, &turn_context.sub_id) - .await - else { - return; - }; - turn_state.lock().await.insert_mcp_tool_approval_metadata( - call_id.to_string(), + ) -> Arc<(Option, McpToolApprovalMetadata)> { + let key = (invocation.server.clone(), call_id.to_string()); + let metadata = Arc::new(( (invocation.server == CODEX_APPS_MCP_SERVER_NAME || is_node_repl_backed_server(&invocation.server)) .then(|| invocation.clone()), metadata, - ); + )); + let mut registry = self + .mcp_tool_approval_metadata + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Keep approval details available while a tool is running, even across turns. + // When a new call starts, clean up entries left by finished calls. + registry.retain(|_, entry| entry.strong_count() != 0); + registry.insert(key, Arc::downgrade(&metadata)); + metadata } - pub(crate) async fn mcp_tool_approval_metadata( + pub(crate) fn mcp_tool_approval_metadata( &self, - sub_id: &str, + server: &str, call_id: &str, ) -> Option<(Option, McpToolApprovalMetadata)> { - let turn_state = self - .input_queue - .turn_state_for_sub_id(&self.active_turn, sub_id) - .await?; - - turn_state.lock().await.mcp_tool_approval_metadata(call_id) + self.mcp_tool_approval_metadata + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&(server.to_string(), call_id.to_string())) + .and_then(std::sync::Weak::upgrade) + .map(|metadata| (*metadata).clone()) } } @@ -1571,8 +1572,7 @@ pub(crate) async fn request_mcp_tool_user_approval( ); if tool_call_mcp_elicitation_enabled { let link_id = sess - .mcp_tool_approval_metadata(&turn_context.sub_id, id) - .await + .mcp_tool_approval_metadata(server, id) .and_then(|(_, metadata)| metadata.link_id); let metadata = McpToolApprovalMetadata { annotations: None, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 980ed317c3..3233728975 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -3309,3 +3309,63 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() { assert_eq!(decision, None); } } + +#[tokio::test] +async fn approval_metadata_is_released_when_the_invocation_future_is_dropped() { + let (session, _) = crate::session::tests::make_session_and_context().await; + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "write_record".to_string(), + arguments: Some(serde_json::json!({"value": 42})), + }; + let metadata = approval_metadata( + Some("connector"), + /*connector_name*/ None, + /*connector_description*/ None, + /*tool_title*/ None, + /*tool_description*/ None, + ); + let mut call = Box::pin(async { + let _approval_metadata = + session.register_mcp_tool_approval_metadata("call", &invocation, metadata.clone()); + std::future::pending::<()>().await; + }); + assert!(futures::poll!(call.as_mut()).is_pending()); + let _other_metadata = + session.register_mcp_tool_approval_metadata("other-call", &invocation, metadata.clone()); + assert_eq!( + session + .mcp_tool_approval_metadata(CODEX_APPS_MCP_SERVER_NAME, "call") + .map(|(invocation, metadata)| (invocation, metadata.connector_id)), + Some((Some(invocation.clone()), Some("connector".to_string()))), + ); + assert!( + session + .mcp_tool_approval_metadata("another-server", "call") + .is_none() + ); + drop(call); + assert!( + session + .mcp_tool_approval_metadata(CODEX_APPS_MCP_SERVER_NAME, "call") + .is_none() + ); + let _next_metadata = + session.register_mcp_tool_approval_metadata("next-call", &invocation, metadata); + let registry = session.mcp_tool_approval_metadata.lock().unwrap(); + let mut keys = registry.keys().cloned().collect::>(); + keys.sort(); + assert_eq!( + keys, + vec![ + ( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + "next-call".to_string() + ), + ( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + "other-call".to_string() + ), + ], + ); +} diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 12bfee4acb..18a8d083c7 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -789,9 +789,8 @@ async fn review_guardian_mcp_elicitation( .meta() .and_then(|meta| meta.get("callId")) .and_then(Value::as_str) - && let Some((Some(invocation), _)) = session - .mcp_tool_approval_metadata(&turn_context.sub_id, call_id) - .await + && let Some((Some(invocation), _)) = + session.mcp_tool_approval_metadata(&request.server_name, call_id) && invocation.server == request.server_name { Some(call_id) @@ -828,9 +827,8 @@ async fn review_guardian_mcp_elicitation( else { return Ok(None); }; - let Some((Some(invocation), metadata)) = session - .mcp_tool_approval_metadata(&turn_context.sub_id, call_id) - .await + let Some((Some(invocation), metadata)) = + session.mcp_tool_approval_metadata(&request.server_name, call_id) else { return Ok(None); }; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index aebfd641e5..7dd4fe066a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -12,6 +12,7 @@ use crate::context::GuardianContextMode; use crate::environment_selection::ThreadEnvironments; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::hook_mcp_executor::CoreHookMcpExecutor; +use crate::mcp_tool_call::McpToolApprovalMetadata; use crate::responses_metadata::CodexResponsesMetadata; use crate::responses_metadata::CodexResponsesRequestKind; use crate::shell_snapshot::ShellSnapshot; @@ -30,6 +31,7 @@ use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSource; @@ -40,6 +42,9 @@ use codex_utils_path::replace_path_and_deduplicate; use std::sync::OnceLock; use tokio::sync::Semaphore; +type McpToolApprovalMetadataMap = + HashMap<(String, String), std::sync::Weak<(Option, McpToolApprovalMetadata)>>; + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -65,6 +70,8 @@ pub(crate) struct Session { pub(super) multi_agent_version: OnceLock, /// Owns invalidation and serializes refreshes without blocking captured calls. pub(super) mcp_refresh: McpRefresh, + /// Non-owning lookup for approval data retained by running MCP invocations. + pub(crate) mcp_tool_approval_metadata: std::sync::Mutex, pub(super) mcp_elicitation_reviewer_handle: OnceLock, pub(super) mcp_elicitation_lifecycle_handle: OnceLock, pub(super) mcp_prewarm_tx: async_channel::Sender<()>, @@ -1625,7 +1632,8 @@ impl Session { windows_sandbox_proxy_settings_mode, multi_agent_version, mcp_refresh: McpRefresh::new(), - mcp_elicitation_reviewer_handle: OnceLock::new(), + mcp_tool_approval_metadata: Default::default(), + mcp_elicitation_reviewer_handle: OnceLock::new(), mcp_elicitation_lifecycle_handle: OnceLock::new(), mcp_prewarm_tx, mcp_prewarm_shutdown: CancellationToken::new(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b77be1ae0d..0152b7b043 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6743,6 +6743,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), mcp_refresh: McpRefresh::new(), + mcp_tool_approval_metadata: Default::default(), mcp_elicitation_reviewer_handle: OnceLock::new(), mcp_elicitation_lifecycle_handle: OnceLock::new(), mcp_prewarm_tx: async_channel::bounded(1).0, @@ -8917,6 +8918,7 @@ where codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), mcp_refresh: McpRefresh::new(), + mcp_tool_approval_metadata: Default::default(), mcp_elicitation_reviewer_handle: OnceLock::new(), mcp_elicitation_lifecycle_handle: OnceLock::new(), mcp_prewarm_tx: async_channel::bounded(1).0, @@ -11245,12 +11247,12 @@ impl SessionTask for GuardianDeniedApprovalTask { async fn run( self: Arc, session: Arc, - ctx: Arc, + _ctx: Arc, _input: Vec, cancellation_token: CancellationToken, ) -> SessionTaskResult { for _ in 0..3 { - crate::guardian::record_guardian_denial_for_test(&session, &ctx, &ctx.sub_id).await; + crate::guardian::record_guardian_denial_for_test(&session).await; } cancellation_token.cancelled().await; @@ -11621,8 +11623,6 @@ async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { .await; let session_for_review = Arc::clone(&sess); - let turn_for_review = Arc::clone(&tc); - let turn_id = tc.sub_id.clone(); let review_thread = std::thread::spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -11630,12 +11630,7 @@ async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { .expect("helper review runtime"); runtime.block_on(async move { for _ in 0..3 { - crate::guardian::record_guardian_denial_for_test( - &session_for_review, - &turn_for_review, - &turn_id, - ) - .await; + crate::guardian::record_guardian_denial_for_test(&session_for_review).await; } }); }); diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 324b39ae46..01b8756e1f 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -19,14 +19,12 @@ use tokio::sync::oneshot; use super::TurnTokenUsage; use crate::agent::control::AgentExecutionGuard; -use crate::mcp_tool_call::McpToolApprovalMetadata; use crate::session::TurnInputQueue; use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; use crate::tasks::AnySessionTask; use codex_protocol::models::AdditionalPermissionProfile; -use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TokenUsage; @@ -93,7 +91,6 @@ pub(crate) struct TurnState { pending_request_permissions: HashMap, pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, - mcp_tool_approval_metadata: HashMap, McpToolApprovalMetadata)>, pending_dynamic_tools: HashMap>, pub(crate) pending_input: TurnInputQueue, mailbox_delivery_phase: MailboxDeliveryPhase, @@ -142,7 +139,6 @@ impl TurnState { self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); - self.mcp_tool_approval_metadata.clear(); self.pending_dynamic_tools.clear(); } @@ -196,23 +192,6 @@ impl TurnState { .remove(&(server_name.to_string(), request_id.clone())) } - pub(crate) fn insert_mcp_tool_approval_metadata( - &mut self, - call_id: String, - invocation: Option, - metadata: McpToolApprovalMetadata, - ) { - self.mcp_tool_approval_metadata - .insert(call_id, (invocation, metadata)); - } - - pub(crate) fn mcp_tool_approval_metadata( - &self, - call_id: &str, - ) -> Option<(Option, McpToolApprovalMetadata)> { - self.mcp_tool_approval_metadata.get(call_id).cloned() - } - pub(crate) fn insert_pending_dynamic_tool( &mut self, key: String, diff --git a/codex-rs/core/src/tools/code_mode/delegate.rs b/codex-rs/core/src/tools/code_mode/delegate.rs index ac8d0505ea..607fd086ff 100644 --- a/codex-rs/core/src/tools/code_mode/delegate.rs +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; +use std::sync::Weak; use codex_code_mode::CellId; use codex_code_mode::CodeModeNestedToolCall; @@ -17,12 +18,12 @@ use tokio::sync::watch; use tokio_util::sync::CancellationToken; use tracing::Instrument; -use super::ExecContext; use super::PUBLIC_TOOL_NAME; use super::submit_nested_tool; use super::telemetry::DispatchInterruption; use super::telemetry::NestedToolDispatchTrace; use super::telemetry::trace_id; +use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::tools::ExecutedToolCalls; use crate::tools::call_trace; @@ -37,6 +38,12 @@ pub(super) struct CodeModeDispatchBroker { executed_tool_calls: ExecutedToolCalls, } +/// Retains the step advertised to one execution, including callbacks after it yields. +pub(super) struct CodeModeCellDelegate { + pub(super) broker: Arc, + pub(super) step_context: Arc, +} + struct CellDispatchGate { ready: watch::Sender, // Keep the original exec item when later waits resume this cell. @@ -105,13 +112,15 @@ impl CodeModeDispatchBroker { pub(super) fn start_turn_worker( &self, - exec: ExecContext, + session: Arc, step_context: Arc, tracker: SharedTurnDiffTracker, ) -> CodeModeDispatchWorker { - let track_completeness = ExecutedToolCalls::is_enabled(&exec.turn.config.features); - let tool_runtime = ToolCallRuntime::new(Arc::clone(&exec.session), step_context, tracker); - let host = Arc::new(CoreTurnHost { exec, tool_runtime }); + let tool_runtime = ToolCallRuntime::new(Arc::clone(&session), step_context, tracker); + let host = Arc::new(CoreTurnHost { + session, + tool_runtime, + }); let dispatch_rx = self.dispatch_rx.clone(); let dispatch_gates = Arc::clone(&self.dispatch_gates); let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); @@ -148,6 +157,7 @@ impl CodeModeDispatchBroker { } DispatchMessage::InvokeTool { invocation, + step_context, mut dispatch_trace, cancellation_token, response_tx, @@ -169,6 +179,16 @@ impl CodeModeDispatchBroker { remove_dispatch_gate(&dispatch_gates, &cell_id); continue; } + let Some(step_context) = step_context + .upgrade() + .filter(|_| !cancellation_token.is_cancelled()) + else { + let _ = response_tx + .send(Err("code mode nested tool call cancelled".to_string())); + continue; + }; + let track_completeness = + ExecutedToolCalls::is_enabled(&step_context.turn.config.features); let host = Arc::clone(&host); let dispatch_gates = Arc::clone(&dispatch_gates); let span = dispatch_trace.span.clone(); @@ -196,6 +216,7 @@ impl CodeModeDispatchBroker { dispatch_trace.interruption = None; host.submit_tool( invocation, + step_context, dispatch_trace.call_id.clone(), cancellation_token.clone(), ) @@ -273,13 +294,13 @@ async fn wait_until_cell_ready_for_dispatch( } } -impl CodeModeSessionDelegate for CodeModeDispatchBroker { +impl CodeModeSessionDelegate for CodeModeCellDelegate { #[tracing::instrument( name = "code_mode.broker.invoke_tool", level = "info", skip_all, fields( - conversation.id = %self.thread_id, + conversation.id = %self.broker.thread_id, cell.id = %invocation.cell_id, runtime_tool_call_id = invocation.runtime_tool_call_id.as_str(), tool_name = invocation.tool_name.name.as_str(), @@ -294,7 +315,7 @@ impl CodeModeSessionDelegate for CodeModeDispatchBroker { Box::pin(async move { let call_id = format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()); call_trace::received( - self.thread_id, + self.broker.thread_id, &invocation.tool_name, &call_id, call_trace::Receipt::CodeModeBroker { @@ -303,16 +324,18 @@ impl CodeModeSessionDelegate for CodeModeDispatchBroker { }, ); let mut dispatch_trace = - Box::new(NestedToolDispatchTrace::new(self.thread_id, call_id)); + Box::new(NestedToolDispatchTrace::new(self.broker.thread_id, call_id)); if cancellation_token.is_cancelled() { dispatch_trace.interruption = Some(DispatchInterruption::Cancelled); return Err("code mode nested tool call cancelled".to_string()); } let (response_tx, response_rx) = oneshot::channel(); // Only the worker can tell whether dispatch beats cancellation once the call is queued. - self.dispatch_tx + self.broker + .dispatch_tx .send(DispatchMessage::InvokeTool { invocation, + step_context: Arc::downgrade(&self.step_context), dispatch_trace, cancellation_token: cancellation_token.clone(), response_tx, @@ -341,7 +364,8 @@ impl CodeModeSessionDelegate for CodeModeDispatchBroker { return Err("code mode notification cancelled".to_string()); } let (response_tx, response_rx) = oneshot::channel(); - self.dispatch_tx + self.broker + .dispatch_tx .send(DispatchMessage::Notify { call_id, cell_id, @@ -362,13 +386,15 @@ impl CodeModeSessionDelegate for CodeModeDispatchBroker { } fn cell_closed(&self, cell_id: &CellId) { - self.close_cell(cell_id); + self.broker.close_cell(cell_id); } } enum DispatchMessage { InvokeTool { invocation: CodeModeNestedToolCall, + // The delegate owns the step while the callback is live; stale queued work must not. + step_context: Weak, dispatch_trace: Box, cancellation_token: CancellationToken, response_tx: oneshot::Sender>, @@ -395,7 +421,7 @@ impl Drop for CodeModeDispatchWorker { } struct CoreTurnHost { - exec: ExecContext, + session: Arc, tool_runtime: ToolCallRuntime, } @@ -403,11 +429,13 @@ impl CoreTurnHost { fn submit_tool( &self, invocation: CodeModeNestedToolCall, + step_context: Arc, call_id: String, cancellation_token: CancellationToken, ) -> impl std::future::Future> + Send + 'static { let invocation = submit_nested_tool( - self.exec.clone(), + Arc::clone(&self.session), + step_context, self.tool_runtime.clone(), invocation, call_id, @@ -421,8 +449,7 @@ impl CoreTurnHost { if text.trim().is_empty() { return Ok(()); } - self.exec - .session + self.session .inject_if_running(vec![ResponseItem::CustomToolCallOutput { id: None, call_id, diff --git a/codex-rs/core/src/tools/code_mode/delegate_tests.rs b/codex-rs/core/src/tools/code_mode/delegate_tests.rs index 70340147c7..1dc6201623 100644 --- a/codex-rs/core/src/tools/code_mode/delegate_tests.rs +++ b/codex-rs/core/src/tools/code_mode/delegate_tests.rs @@ -1,4 +1,4 @@ -//! Tracing identity coverage for code-mode callbacks outside local turn spans. +//! Callback tracing identity and originating-step lifetime coverage. use std::collections::BTreeMap; use std::sync::Arc; @@ -20,8 +20,9 @@ use tracing_subscriber::layer::Context; use tracing_subscriber::prelude::*; use tracing_subscriber::registry::Registry; +use super::CodeModeCellDelegate; use super::CodeModeDispatchBroker; -use super::ExecContext; +use super::DispatchMessage; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; use crate::tools::context::FunctionToolOutput; @@ -31,6 +32,7 @@ use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolRegistry; use crate::tools::router::ToolRouter; use crate::turn_diff_tracker::TurnDiffTracker; +use tokio::sync::oneshot; #[derive(Default)] struct DispatchFields(BTreeMap); @@ -125,17 +127,18 @@ async fn detached_code_mode_callback_keeps_thread_id_on_dispatch_span() -> anyho let subscriber = tracing_subscriber::registry().with(DispatchCapture(Arc::clone(&records))); let _untraced = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default()); let _subscriber = tracing::subscriber::set_default(subscriber); + let delegate = CodeModeCellDelegate { + broker: Arc::clone(&broker), + step_context: Arc::clone(&step), + }; let _worker = broker.start_turn_worker( - ExecContext { - session: Arc::new(session), - turn, - }, + Arc::new(session), step, Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), ); // Process-owned code-mode callbacks run on a fresh task without a turn span. let result = tokio::spawn(async move { - broker + delegate .invoke_tool( CodeModeNestedToolCall { cell_id, @@ -170,3 +173,63 @@ async fn detached_code_mode_callback_keeps_thread_id_on_dispatch_span() -> anyho uuid::Uuid::parse_str(uuid)?; Ok(()) } + +#[tokio::test] +async fn dropped_tool_callbacks_release_the_origin_before_dispatch() { + let (session, turn) = make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + let broker = Arc::new(CodeModeDispatchBroker::new( + session.thread_id, + /*executed_tool_calls*/ Default::default(), + )); + let delegate = CodeModeCellDelegate { + broker: Arc::clone(&broker), + step_context: StepContext::for_test(Arc::clone(&turn)), + }; + let origin = Arc::downgrade(&delegate.step_context); + let cell_id = CellId::new("queued-cell".to_string()); + let cancellation_token = CancellationToken::new(); + let mut callback = delegate.invoke_tool( + CodeModeNestedToolCall { + cell_id: cell_id.clone(), + runtime_tool_call_id: "call-a".to_string(), + tool_name: codex_tools::ToolName::plain("test-tool"), + tool_kind: codex_code_mode::CodeModeToolKind::Function, + input: None, + }, + cancellation_token.clone(), + ); + assert!(futures::poll!(callback.as_mut()).is_pending()); + assert_eq!(broker.dispatch_rx.len(), 1); + drop(callback); + drop(delegate); + assert!( + origin.upgrade().is_none(), + "queued work must not own the step" + ); + + // Observe the queued message's result after its original receiver was dropped. + let mut message = broker.dispatch_rx.try_recv().unwrap(); + let (response_tx, response_rx) = oneshot::channel(); + let DispatchMessage::InvokeTool { + response_tx: queued_response, + .. + } = &mut message + else { + panic!("expected tool invocation"); + }; + *queued_response = response_tx; + broker.dispatch_tx.send(message).await.unwrap(); + broker.mark_cell_ready_for_dispatch(&cell_id, /*originating_item_id*/ None); + let _worker = broker.start_turn_worker( + Arc::clone(&session), + StepContext::for_test(Arc::clone(&turn)), + Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + ); + let response = response_rx.await; + assert_eq!( + response.unwrap(), + Err("code mode nested tool call cancelled".to_string()), + ); +} diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index f7ceb3c7c7..d131019fd5 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -34,7 +34,7 @@ impl CodeModeExecuteHandler { async fn execute( &self, session: std::sync::Arc, - step_context: std::sync::Arc, + step_context: Arc, call_id: String, originating_item_id: Option, code: String, @@ -71,13 +71,16 @@ impl CodeModeExecuteHandler { .session .services .code_mode_service - .execute(codex_code_mode::ExecuteRequest { - tool_call_id: call_id.clone(), - enabled_tools, - source: args.code.clone(), - yield_time_ms: args.yield_time_ms, - max_output_tokens: args.max_output_tokens, - }) + .execute( + codex_code_mode::ExecuteRequest { + tool_call_id: call_id.clone(), + enabled_tools, + source: args.code.clone(), + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, + }, + Arc::clone(&step_context), + ) .await .map_err(FunctionCallError::RespondToModel)?; let cell_id = started_cell.cell_id.clone(); diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 355f0a195c..ab100b4d9f 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -47,6 +47,7 @@ use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy; use codex_utils_output_truncation::truncate_function_output_items_with_policy; +use delegate::CodeModeCellDelegate; use delegate::CodeModeDispatchBroker; use delegate::CodeModeDispatchWorker; pub(crate) use execute_handler::CodeModeExecuteHandler; @@ -125,14 +126,16 @@ impl CodeModeService { pub(crate) async fn execute( &self, mut request: codex_code_mode::ExecuteRequest, + step_context: Arc, ) -> Result { request .yield_time_ms .get_or_insert(self.default_exec_yield_time_ms); - self.session() - .await? - .execute(request, self.dispatch_broker.clone()) - .await + let delegate = Arc::new(CodeModeCellDelegate { + broker: Arc::clone(&self.dispatch_broker), + step_context, + }); + self.session().await?.execute(request, delegate).await } pub(crate) async fn wait( @@ -209,18 +212,13 @@ impl CodeModeService { step_context: Arc, tracker: SharedTurnDiffTracker, ) -> Option { - let turn = &step_context.turn; if !step_context.tool_router.requires_code_mode_worker() { return None; } - let exec = ExecContext { - session: Arc::clone(session), - turn: Arc::clone(turn), - }; Some( self.dispatch_broker - .start_turn_worker(exec, step_context, tracker), + .start_turn_worker(Arc::clone(session), step_context, tracker), ) } @@ -346,7 +344,8 @@ fn truncate_code_mode_result( // Submit synchronously so the recorder sees the call before the cell's dispatch gate closes. fn submit_nested_tool( - exec: ExecContext, + session: Arc, + step_context: Arc, tool_runtime: ToolCallRuntime, invocation: CodeModeNestedToolCall, call_id: String, @@ -362,8 +361,8 @@ fn submit_nested_tool( tool_kind, input, } = invocation; - let thread_id = exec.session.thread_id; - let turn_id = exec.turn.sub_id.clone(); + let thread_id = session.thread_id; + let turn_id = step_context.turn.sub_id.clone(); let tool_name = tool_name.with_default_namespace(); // A cell can outlive a turn; the broker records arrival before a dispatching turn is known. tracing::event!( @@ -402,16 +401,17 @@ fn submit_nested_tool( payload, encrypted_function_args: None, }; - exec.session + session .services .analytics_events_client .track_code_mode_tool_call(codex_analytics::CodeModeToolCallFact::ChildStarted { - thread_id: exec.session.thread_id.to_string(), - turn_id: exec.turn.sub_id.clone(), + thread_id: session.thread_id.to_string(), + turn_id: step_context.turn.sub_id.clone(), call_id: call.call_id.clone(), cell_id: cell_id.to_string(), }); let result = tool_runtime.handle_tool_call_with_source( + step_context, call, ToolCallSource::CodeMode { cell_id: cell_id.to_string(), diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index e43aa5ff4d..8cbc861b0f 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -79,7 +79,9 @@ impl ToolCallRuntime { ) -> impl std::future::Future> { let error_call = call.clone(); let source = call.direct_source(); - let future = self.handle_tool_call_with_source(call, source, cancellation_token); + let step_context = Arc::clone(&self.step_context); + let future = + self.handle_tool_call_with_source(step_context, call, source, cancellation_token); async move { match future.await { Ok(response) => Ok(response.into_response()), @@ -94,21 +96,20 @@ impl ToolCallRuntime { #[instrument(level = "trace", skip_all)] pub(crate) fn handle_tool_call_with_source( self, + step_context: Arc, call: ToolCall, source: ToolCallSource, cancellation_token: CancellationToken, ) -> impl std::future::Future> { - self.session.services.executed_tool_calls.record_tool_call( - &call, - &source, - &self.step_context, - ); - let router = &self.step_context.tool_router; + self.session + .services + .executed_tool_calls + .record_tool_call(&call, &source, &step_context); + let router = &step_context.tool_router; let supports_parallel = router.tool_supports_parallel(&call); let tool_runtime = router.tool_runtime(&call.tool_name); let router = Arc::clone(router); let session = Arc::clone(&self.session); - let step_context = Arc::clone(&self.step_context); let turn = Arc::clone(&step_context.turn); let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 4243ae75da..e4950170e0 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3342,6 +3342,7 @@ text((await tools.exec_command({{cmd: "printf 'phase 3'"}})).output); let first_completion = responses::mount_sse_once( &server, sse(vec![ + responses::ev_response_created("resp-2"), ev_assistant_message("msg-1", "waiting"), ev_completed("resp-2"), ]), @@ -3453,8 +3454,8 @@ text((await tools.exec_command({{cmd: "printf 'phase 3'"}})).output); ); assert_eq!(text_item(&third_items, /*index*/ 1), "phase 3"); - // Resuming the cell changes the response id, but its nested calls retain - // the original exec item's identity for request-local freshness accounting. + // Nested calls retain the original exec item and turn context when later + // turns resume the cell with wait. let originating_items = observer.originating_items.lock().unwrap(); let wrapper_item = originating_items[0] .1 @@ -3470,15 +3471,12 @@ text((await tools.exec_command({{cmd: "printf 'phase 3'"}})).output); let observed = observer.response_ids.lock().unwrap(); let mut nested_response_ids = observed .iter() + .skip_while(|(tool, _)| tool != "wait") .filter(|(tool, _)| tool == "exec_command") - .filter_map(|(_, response_id)| response_id.clone()) - .skip_while(|response_id| response_id != &"resp-3".to_owned()) + .map(|(_, response_id)| response_id.clone()) .collect::>(); nested_response_ids.dedup(); - assert_eq!( - nested_response_ids, - vec!["resp-3".to_owned(), "resp-5".to_owned()] - ); + assert_eq!(nested_response_ids, vec![Some("resp-2".to_owned())]); assert_eq!( observed .iter() @@ -6119,6 +6117,93 @@ impl<'call> ToolExecutor> for NamespacedCustomTool { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn yielded_code_mode_tool_callbacks_keep_their_originating_step() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::::new(); + extensions.tool_contributor(Arc::new(NamespacedCustomTool { + generation: 0, + generations: Arc::new(AtomicUsize::new(0)), + })); + let mut builder = test_codex() + .with_extensions(Arc::new(extensions.build())) + .with_model_info_override("gpt-5.4", |model| { + model.tool_mode = Some(ToolMode::CodeMode); + model.experimental_supported_tools = vec!["test_sync_tool".to_string()]; + }) + .with_config(|config| { + config.features.enable(Feature::CodeMode).unwrap(); + }); + let test = builder.build_with_auto_env(&server).await?; + // A cannot invoke its editor until B is executing in the next turn. + let barrier = r#"await tools.test_sync_tool({barrier: { + id: "code-mode-origin-turns", participants: 2, timeout_ms: 60000 + }});"#; + responses::mount_sse_once(&server, sse(vec![ + ev_response_created("resp-a"), + ev_custom_tool_call("call-a", "exec", &format!( + "// @exec: {{\"yield_time_ms\": 1}}\n{barrier}\ntext('A_ORIGIN:' + (await tools.editor__apply_patch('A')).generation);" + )), + ev_completed("resp-a"), + ])).await; + let yielded = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-a", "waiting for the next turn"), + ev_completed("resp-a-done"), + ]), + ) + .await; + test.submit_turn("start A and leave it running").await?; + let cell_id = extract_running_cell_id(text_item( + &custom_tool_output_items(&yielded.single_request(), "call-a"), + /*index*/ 0, + )); + + responses::mount_sse_once(&server, sse(vec![ + ev_response_created("resp-b"), + ev_custom_tool_call("call-b", "exec", &format!( + "// @exec: {{\"yield_time_ms\": 60000}}\n{barrier}\ntext('B_ORIGIN:' + (await tools.editor__apply_patch('B')).generation);" + )), + ev_completed("resp-b"), + ])).await; + let b_finished = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-wait"), + responses::ev_function_call( + "call-wait-a", + "wait", + &serde_json::json!({ + "cell_id": cell_id, "yield_time_ms": 60000, + }) + .to_string(), + ), + ev_completed("resp-wait"), + ]), + ) + .await; + let completed = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-b", "both cells finished"), + ev_completed("resp-b-done"), + ]), + ) + .await; + test.submit_turn("run B, then wait for A").await?; + assert_eq!( + custom_tool_output_body_and_success(&b_finished.single_request(), "call-b").0, + "B_ORIGIN:3" + ); + let live = completed.single_request(); + let a_output = function_tool_output_items(&live, "call-wait-a"); + assert!(text_item(&a_output, /*index*/ 0).starts_with("Script completed")); + assert_eq!(text_item(&a_output, /*index*/ 1), "A_ORIGIN:1"); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_exposes_and_dispatches_namespaced_custom_tools() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/guardian_mcp_elicitation.rs b/codex-rs/core/tests/suite/guardian_mcp_elicitation.rs index 538ce35d03..914acdb9fd 100644 --- a/codex-rs/core/tests/suite/guardian_mcp_elicitation.rs +++ b/codex-rs/core/tests/suite/guardian_mcp_elicitation.rs @@ -773,3 +773,153 @@ async fn user_review_preserves_unsupported_guardian_elicitations(mode: &str) -> test.codex.shutdown_and_wait().await?; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn yielded_code_mode_elicitation_keeps_live_invocation_metadata() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "the MCP fixture requires a host Python interpreter"); + + let server = responses::start_mock_server().await; + let meta = json!([{ + "codex_strict_auto_review": true, + "codex_sensitive_action": true, + "tool_name": "write_record", + "tool_params": {"value": 42}, + "connector_id": "inner-connector", + }]); + let mcp_servers = serde_json::from_value(json!({ + "node_repl": { + "command": if cfg!(windows) { "python" } else { "python3" }, + "args": ["-u", "-c", ELICITATION_SERVER, meta.to_string(), "forward"], + "default_tools_approval_mode": "approve", + } + }))?; + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.tool_mode = Some(codex_protocol::openai_models::ToolMode::CodeMode); + model.experimental_supported_tools = vec!["test_sync_tool".to_string()]; + }) + .with_config(move |config| { + config + .features + .enable(codex_features::Feature::CodeMode) + .unwrap(); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::User; + config.mcp_servers.set(mcp_servers).unwrap(); + }); + let test = builder.build_with_auto_env(&server).await?; + wait_for_mcp_server(&test.codex, "node_repl").await?; + let barrier = r#"await tools.test_sync_tool({barrier: { + id: "elicitation-origin", participants: 2, timeout_ms: 60000 + }});"#; + responses::mount_sse_once(&server, responses::sse(vec![ + responses::ev_response_created("origin-a"), + responses::ev_custom_tool_call("cell-a", "exec", &format!( + "// @exec: {{\"yield_time_ms\": 1}}\n{barrier}\ntext(await tools.mcp__node_repl__js({{code: 'write_record()'}}));" + )), + responses::ev_completed("origin-a"), + ])).await; + let yielded = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("a-finished")]), + ) + .await; + test.submit_text_turn("Start A and leave its cell running.") + .await?; + let output = yielded.single_request().custom_tool_call_output("cell-a"); + let text = output["output"] + .as_str() + .or_else(|| output["output"][0]["text"].as_str()) + .context("A should return text")?; + let cell_id = text + .strip_prefix("Script running with cell ID ") + .and_then(|text| text.lines().next()) + .context("A should yield a running cell")? + .to_string(); + + responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_custom_tool_call("cell-b", "exec", barrier), + responses::ev_completed("b-started"), + ]), + ) + .await; + let guardian = responses::mount_sse_once_match( + &server, + body_partial_json(json!({"client_metadata": {"x-openai-subagent": "guardian"}})), + responses::sse(vec![ + responses::ev_assistant_message( + "review-result", + &json!({ + "risk_level": "low", "user_authorization": "high", "outcome": "allow", + "rationale": "The user requested the action.", + }) + .to_string(), + ), + responses::ev_completed("review-done"), + ]), + ) + .await; + responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_function_call( + "wait-a", + "wait", + &json!({ + "cell_id": cell_id, "yield_time_ms": 60000, + }) + .to_string(), + ), + responses::ev_completed("b-wait"), + ]), + ) + .await; + responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("b-finished")]), + ) + .await; + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Run B and wait for A.".into(), + text_elements: Vec::new(), + }])) + .await?; + let mut invocation_id = None; + let mut assessment_target = None; + loop { + let event = + tokio::time::timeout(Duration::from_secs(60), test.codex.next_event()).await??; + match event.msg { + EventMsg::ItemCompleted(event) => { + if let TurnItem::McpToolCall(item) = event.item { + invocation_id = Some(item.id); + } + } + EventMsg::GuardianAssessment(assessment) + if assessment.status == GuardianAssessmentStatus::Approved => + { + assessment_target = assessment.target_item_id; + } + EventMsg::ElicitationRequest(_) => panic!("strict review must not prompt the user"), + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + assert!(invocation_id.is_some(), "A's tool must complete during B"); + assert_eq!(assessment_target, invocation_id); + assert_eq!( + guardian + .requests() + .iter() + .filter( + |request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian" + ) + .count(), + 1 + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index 245fba5db8..466bb77b7e 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -2208,3 +2208,170 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn yielded_code_mode_denials_interrupt_the_servicing_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "Guardian approval actions require host-native paths" + ); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.tool_mode = Some(codex_protocol::openai_models::ToolMode::CodeMode); + model.experimental_supported_tools = vec!["test_sync_tool".to_string()]; + }) + .with_config(|config| { + config.features.enable(Feature::CodeMode).unwrap(); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }); + let test = builder.build_with_auto_env(&server).await?; + let barrier = r#"await tools.test_sync_tool({barrier: { + id: "guardian-origin", participants: 2, timeout_ms: 60000 + }});"#; + let code = format!( + r#"// @exec: {{"yield_time_ms": 1}} +{barrier} +for (let index = 0; index < 6; index++) {{ + try {{ + text(await tools.exec_command({{ + cmd: `echo review-${{index}}`, + sandbox_permissions: "require_escalated", + justification: "Exercise originating-cell Guardian reviews." + }})); + }} catch (error) {{ + text(String(error)); + }} +}} +"# + ); + core_test_support::responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("origin-a"), + core_test_support::responses::ev_custom_tool_call("cell-a", "exec", &code), + ev_completed("origin-a"), + ]), + ) + .await; + let yielded = core_test_support::responses::mount_sse_once( + &server, + sse(vec![ev_completed("a-finished")]), + ) + .await; + test.submit_text_turn("Start A and leave its cell running.") + .await?; + let output = yielded.single_request().custom_tool_call_output("cell-a"); + let text = output["output"] + .as_str() + .or_else(|| output["output"][0]["text"].as_str()) + .context("A should return text")?; + let cell_id = text + .strip_prefix("Script running with cell ID ") + .and_then(|text| text.lines().next()) + .context("A should yield a running cell")? + .to_string(); + + core_test_support::responses::mount_sse_once( + &server, + sse(vec![ + core_test_support::responses::ev_custom_tool_call("cell-b", "exec", barrier), + ev_completed("b-started"), + ]), + ) + .await; + let mut reviews = Vec::new(); + // An approval resets B's consecutive count; only the final three denials interrupt it. + for outcome in ["deny", "deny", "allow", "deny", "deny", "deny"] { + reviews.push( + core_test_support::responses::mount_sse_once_match( + &server, + wiremock::matchers::body_partial_json(json!({ + "client_metadata": {"x-openai-subagent": "guardian"} + })), + sse(vec![ + ev_assistant_message( + "review", + &json!({ + "risk_level": if outcome == "allow" { "low" } else { "high" }, + "user_authorization": "high", "outcome": outcome, + "rationale": "Test decision for the delayed cell.", + }) + .to_string(), + ), + ev_completed("review-done"), + ]), + ) + .await, + ); + } + core_test_support::responses::mount_sse_once( + &server, + sse(vec![ + ev_function_call( + "wait-a", + "wait", + &json!({ + "cell_id": cell_id, "yield_time_ms": 60000, + }) + .to_string(), + ), + ev_completed("b-wait"), + ]), + ) + .await; + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Run B and wait for A.".into(), + text_elements: Vec::new(), + }])) + .await?; + let mut active_id = None; + let mut warning_id = None; + loop { + let event = + tokio::time::timeout(Duration::from_secs(60), test.codex.next_event()).await??; + match event.msg { + EventMsg::TurnStarted(_) => active_id = Some(event.id), + EventMsg::GuardianWarning(warning) + if warning.message.contains("too many approval requests") => + { + assert!( + warning + .message + .contains("3 consecutive, 5 in the last 50 reviews"), + "{}", + warning.message + ); + warning_id = Some(event.id); + } + EventMsg::TurnAborted(aborted) => { + assert_eq!(aborted.reason, TurnAbortReason::Interrupted); + assert_eq!(Some(event.id), active_id); + break; + } + EventMsg::TurnComplete(_) => panic!("B must be interrupted by A's denials"), + _ => {} + } + } + assert!(active_id.is_some()); + assert_eq!(warning_id, active_id); + for review in reviews { + assert_eq!( + review + .requests() + .iter() + .filter( + |request| request.body_json()["client_metadata"]["x-openai-subagent"] + == "guardian" + ) + .count(), + 1 + ); + } + Ok(()) +} diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2e403ded07..93765c59ca 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -2498,6 +2498,12 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { .start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp image tool")) .await?; + let turn_id = core_test_support::wait_for_event_match(&fixture.codex, |event| match event { + EventMsg::TurnStarted(started) => Some(started.turn_id.clone()), + _ => None, + }) + .await; + // Wait for tool begin/end and final completion. let begin_event = wait_for_event(&fixture.codex, |ev| { matches!(ev, EventMsg::McpToolCallBegin(_)) @@ -2509,6 +2515,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { assert_eq!( begin, McpToolCallBeginEvent { + turn_id: turn_id.clone(), call_id: call_id.to_string(), invocation: McpInvocation { server: server_name.to_string(), @@ -2532,6 +2539,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { let EventMsg::McpToolCallEnd(end) = end_event else { unreachable!("end"); }; + assert_eq!(end.turn_id, turn_id); assert_eq!(end.call_id, call_id); assert_eq!( end.invocation, diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs index 2ef68b9b1b..bfea90b558 100644 --- a/codex-rs/protocol/src/legacy_events.rs +++ b/codex-rs/protocol/src/legacy_events.rs @@ -472,9 +472,10 @@ impl FileChangeItem { } impl McpToolCallItem { - pub fn as_legacy_begin_event(&self) -> EventMsg { + pub fn as_legacy_begin_event(&self, turn_id: String) -> EventMsg { EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: self.id.clone(), + turn_id, invocation: McpInvocation { server: self.server.clone(), tool: self.tool.clone(), @@ -490,7 +491,7 @@ impl McpToolCallItem { }) } - pub fn as_legacy_end_event(&self) -> Option { + pub fn as_legacy_end_event(&self, turn_id: String) -> Option { let result = match (&self.result, &self.error) { (Some(result), _) => Ok(result.clone()), (None, Some(error)) => Err(error.message.clone()), @@ -499,6 +500,7 @@ impl McpToolCallItem { Some(EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: self.id.clone(), + turn_id, invocation: McpInvocation { server: self.server.clone(), tool: self.tool.clone(), @@ -542,7 +544,10 @@ impl TurnItem { .as_legacy_end_event(String::new()) .into_iter() .collect(), - TurnItem::McpToolCall(item) => item.as_legacy_end_event().into_iter().collect(), + TurnItem::McpToolCall(item) => item + .as_legacy_end_event(String::new()) + .into_iter() + .collect(), TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning), TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()], } @@ -562,7 +567,7 @@ impl HasLegacyEvent for ItemStartedEvent { })] } TurnItem::FileChange(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())], - TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event()], + TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())], TurnItem::CommandExecution(item) => { vec![item.as_legacy_begin_event(self.turn_id.clone(), self.started_at_ms)] } @@ -581,6 +586,10 @@ impl HasLegacyEvent for ItemStartedEvent { impl HasLegacyEvent for ItemCompletedEvent { fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { match &self.item { + TurnItem::McpToolCall(item) => item + .as_legacy_end_event(self.turn_id.clone()) + .into_iter() + .collect(), TurnItem::FileChange(item) => item .as_legacy_end_event(self.turn_id.clone()) .into_iter() diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0c79853a35..81d799b615 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2617,6 +2617,9 @@ pub struct McpInvocation { pub struct McpToolCallBeginEvent { /// Identifier so this can be paired with the McpToolCallEnd event. pub call_id: String, + /// Originating turn; absent in older rollout records. + #[serde(default)] + pub turn_id: String, pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -2646,6 +2649,9 @@ pub struct McpToolCallBeginEvent { pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, + /// Originating turn; absent in older rollout records. + #[serde(default)] + pub turn_id: String, pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -5361,6 +5367,7 @@ mod tests { assert_eq!(legacy_events.len(), 1); match &legacy_events[0] { EventMsg::McpToolCallBegin(event) => { + assert_eq!(event.turn_id, "turn-1"); assert_eq!(event.call_id, "mcp-1"); assert_eq!(event.invocation.server, "server"); assert_eq!(event.invocation.tool, "tool"); @@ -5486,6 +5493,7 @@ mod tests { assert_eq!(legacy_events.len(), 1); match &legacy_events[0] { EventMsg::McpToolCallEnd(event) => { + assert_eq!(event.turn_id, "turn-1"); assert_eq!(event.call_id, "mcp-1"); assert_eq!(event.invocation.server, "server"); assert_eq!(event.invocation.tool, "tool"); diff --git a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs index a6eb63d75a..27d4c885d0 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs @@ -146,7 +146,7 @@ pub(super) fn completed_item( error, duration: Some(event.duration), }), - None, + (!event.turn_id.is_empty()).then(|| event.turn_id.clone()), )) } EventMsg::WebSearchEnd(event) => Some(( diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index 8dfad90b9e..bad11829c6 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -507,6 +507,18 @@ async fn migration_keeps_late_completions_in_their_original_turn() { started("current"), user_message("current question"), exec_completion("old", "call-old"), + serde_json::from_value(json!({ + "type": "event_msg", + "payload": { + "type": "mcp_tool_call_end", + "call_id": "mcp-old", + "turn_id": "old", + "invocation": {"server": "slack", "tool": "search", "arguments": {}}, + "duration": {"secs": 0, "nanos": 0}, + "result": {"Ok": {"content": []}} + } + })) + .expect("build late legacy MCP completion"), agent_message("current answer"), completed("current"), ], @@ -545,7 +557,10 @@ async fn migration_keeps_late_completions_in_their_original_turn() { .iter() .map(|item| item.turn_id.as_str()) .collect::>(); - assert_eq!(item_turn_ids, vec!["old", "current", "old", "current"]); + assert_eq!( + item_turn_ids, + vec!["old", "current", "old", "old", "current"] + ); } #[tokio::test]