From 8ace915aced81ed841e34fa069b2e489c324731c Mon Sep 17 00:00:00 2001 From: Chris Dong Date: Wed, 16 Sep 2026 23:22:31 +0000 Subject: [PATCH 001/373] Attribute analytics events to realtime voice sessions (#46058) ## What changed Add `voice_session_id` to turn, app-use, skill-invocation, and MCP tool-call analytics events. Track realtime session lifecycle and handoff markers to preserve attribution when a handoff starts a turn after the voice session closes, or steers an active turn. Keep that attribution from carrying over to subsequent text turns. Record handoff markers using only the thread ID, without retaining transcript content in analytics facts. ## Testing Add coverage for attribution after realtime closure, active-turn steering without tagging the next text turn, and handoff markers that exclude transcripts. Extend event serialization and MCP tool-call tests for `voice_session_id`. GitOrigin-RevId: 024b0e7e4c6fbaf8325574de6c24c048bb4ca032 --- .../analytics/src/analytics_client_tests.rs | 263 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 12 + codex-rs/analytics/src/client_tests.rs | 31 +++ codex-rs/analytics/src/events.rs | 4 + codex-rs/analytics/src/facts.rs | 3 + codex-rs/analytics/src/reducer.rs | 67 ++++- 6 files changed, 379 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d65451cd80ca..3e81ed54eb1a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -151,6 +151,8 @@ use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadArchivedNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadRealtimeClosedNotification; +use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSource as AppServerThreadSource; use codex_app_server_protocol::ThreadStartResponse; @@ -189,6 +191,7 @@ use codex_protocol::protocol::HookExecutionMode; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSource; @@ -1200,6 +1203,7 @@ fn app_used_event_serializes_expected_shape() { invocation_type: Some(InvocationType::Implicit), }, ), + voice_session_id: None, elicitation_type: None, }, }); @@ -1218,6 +1222,7 @@ fn app_used_event_serializes_expected_shape() { "product_client_id": TEST_PRODUCT_CLIENT_ID, "invoke_type": "implicit", "model_slug": "gpt-5", + "voice_session_id": null, "elicitation_type": null } }) @@ -4600,6 +4605,7 @@ async fn reducer_ingests_skill_invoked_fact() { "remote_plugin_id": null, "thread_id": "thread-1", "turn_id": "turn-1", + "voice_session_id": null, "invoke_type": "explicit", "model_slug": "gpt-5" } @@ -4607,6 +4613,240 @@ async fn reducer_ingests_skill_invoked_fact() { ); } +#[tokio::test] +async fn voice_handoff_attributes_plugin_events_after_realtime_closes() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + ingest_turn_prerequisites( + &mut reducer, + &mut events, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + /*include_token_usage*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ThreadRealtimeStarted( + ThreadRealtimeStartedNotification { + thread_id: "thread-2".to_string(), + realtime_session_id: Some("work-voice-123".to_string()), + version: RealtimeConversationVersion::V2, + }, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::RealtimeHandoffRequested { + thread_id: "thread-2".to_string(), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ThreadRealtimeClosed( + ThreadRealtimeClosedNotification { + thread_id: "thread-2".to_string(), + reason: None, + }, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(AppUsedInput { + tracking: test_tracking_context("thread-2", "turn-2"), + app: AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + elicitation_type: None, + })), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(SkillInvokedInput { + tracking: test_tracking_context("thread-2", "turn-2"), + invocations: vec![SkillInvocation { + skill_name: "sample:doc".to_string(), + location: SkillInvocationLocation::Resource { + id: "resource-1".to_string(), + skill_id: Some("sample:doc".to_string()), + scope: None, + }, + plugin_id: Some("sample@test".to_string()), + remote_plugin_id: None, + invocation_type: InvocationType::Explicit, + }], + })), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut events, + ) + .await; + + for event_type in ["codex_app_used", "skill_invocation", "codex_turn_event"] { + let event = events + .iter() + .map(|event| serde_json::to_value(event).expect("serialize analytics event")) + .find(|event| event["event_type"] == event_type) + .unwrap_or_else(|| panic!("missing {event_type}")); + assert_eq!(event["event_params"]["voice_session_id"], "work-voice-123"); + } + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-3", + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(AppUsedInput { + tracking: test_tracking_context("thread-2", "turn-3"), + app: AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + elicitation_type: None, + })), + &mut events, + ) + .await; + let text_app = serde_json::to_value(events.last().expect("text app event")) + .expect("serialize text app event"); + assert_eq!(text_app["event_params"]["voice_session_id"], json!(null)); +} + +#[tokio::test] +async fn voice_handoff_steering_active_turn_does_not_tag_next_text_turn() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ThreadRealtimeStarted( + ThreadRealtimeStartedNotification { + thread_id: "thread-2".to_string(), + realtime_session_id: Some("work-voice-123".to_string()), + version: RealtimeConversationVersion::V2, + }, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::RealtimeHandoffRequested { + thread_id: "thread-2".to_string(), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ThreadRealtimeClosed( + ThreadRealtimeClosedNotification { + thread_id: "thread-2".to_string(), + reason: None, + }, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(AppUsedInput { + tracking: test_tracking_context("thread-2", "turn-2"), + app: AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + elicitation_type: None, + })), + &mut events, + ) + .await; + let voice_app = serde_json::to_value(events.last().expect("voice app event")) + .expect("serialize voice app event"); + assert_eq!( + voice_app["event_params"]["voice_session_id"], + "work-voice-123" + ); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-3", + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(AppUsedInput { + tracking: test_tracking_context("thread-2", "turn-3"), + app: AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + elicitation_type: None, + })), + &mut events, + ) + .await; + let text_app = serde_json::to_value(events.last().expect("text app event")) + .expect("serialize text app event"); + assert_eq!(text_app["event_params"]["voice_session_id"], json!(null)); +} + #[tokio::test] async fn reducer_includes_plugin_ids_for_plugin_skill_invocations() { let mut reducer = AnalyticsReducer::default(); @@ -5050,6 +5290,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), session_id: "session-thread-2".to_string(), turn_id: "turn-2".to_string(), + voice_session_id: None, root_turn_id: Some("turn-2".to_string()), turn_trigger: Some("user".to_string()), codex_turn_source: Some("composer".to_string()), @@ -5127,6 +5368,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "session_id": "session-thread-2", "turn_id": "turn-2", + "voice_session_id": null, "root_turn_id": "turn-2", "turn_trigger": "user", "codex_turn_source": "composer", @@ -5645,6 +5887,19 @@ async fn turn_event_counts_completed_tool_items() { ) .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ThreadRealtimeStarted( + ThreadRealtimeStartedNotification { + thread_id: "thread-2".to_string(), + realtime_session_id: Some("work-voice-456".to_string()), + version: RealtimeConversationVersion::V2, + }, + ))), + &mut out, + ) + .await; + let mcp_tool_call_item = |status, duration_ms| ThreadItem::McpToolCall { id: "mcp-1".to_string(), server: "server".to_string(), @@ -5843,6 +6098,10 @@ async fn turn_event_counts_completed_tool_items() { .expect("MCP tool call event should be emitted"); let payload = serde_json::to_value(mcp_tool_call_event).expect("serialize MCP tool call event"); assert_eq!(payload["event_params"]["plugin_id"], json!("sample@test")); + assert_eq!( + payload["event_params"]["voice_session_id"], + "work-voice-456" + ); assert_eq!( payload["event_params"]["connector_id"], json!("connector-test") @@ -5866,6 +6125,10 @@ async fn turn_event_counts_completed_tool_items() { .expect("turn event should be emitted"); let payload = serde_json::to_value(turn_event).expect("serialize turn event"); assert_eq!(payload["event_params"]["root_turn_id"], "root-ancestor"); + assert_eq!( + payload["event_params"]["voice_session_id"], + "work-voice-456" + ); assert_eq!(payload["event_params"]["total_tool_call_count"], json!(9)); assert_eq!(payload["event_params"]["shell_command_count"], json!(1)); assert_eq!(payload["event_params"]["file_change_count"], json!(1)); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 410cf7def4c1..373e4e2bea75 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -739,11 +739,23 @@ impl AnalyticsEventsClient { /// Records analytics-relevant notifications without cloning ignored variants. pub fn track_notification(&self, notification: &ServerNotification) { + if let ServerNotification::ThreadRealtimeItemAdded(handoff) = notification { + if handoff.item.get("type").and_then(serde_json::Value::as_str) + == Some("handoff_request") + { + self.record_fact(AnalyticsFact::RealtimeHandoffRequested { + thread_id: handoff.thread_id.clone(), + }); + } + return; + } if !matches!( notification, ServerNotification::ThreadArchived(_) | ServerNotification::ThreadClosed(_) | ServerNotification::ThreadUnarchived(_) + | ServerNotification::ThreadRealtimeStarted(_) + | ServerNotification::ThreadRealtimeClosed(_) | ServerNotification::TurnStarted(_) | ServerNotification::TurnCompleted(_) | ServerNotification::TurnDiffUpdated(_) diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 5cc40f4f7216..5175ec1bfd4d 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadArchivedNotification; use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; @@ -149,6 +150,7 @@ fn sample_skill_track_event(thread_id: &str, plugin_id: Option<&str>) -> TrackEv remote_plugin_id: None, thread_id: Some(thread_id.to_string()), turn_id: Some("turn-1".to_string()), + voice_session_id: None, invoke_type: Some(InvocationType::Explicit), model_slug: Some("gpt-5.1-codex".to_string()), }, @@ -236,6 +238,7 @@ fn sample_mcp_tool_call_event(thread_id: &str, plugin_id: Option<&str>) -> Track mcp_error_present: false, plugin_id: plugin_id.map(str::to_string), connector_id: None, + voice_session_id: None, elicitation_type: None, }, }) @@ -999,6 +1002,34 @@ fn track_notification_only_enqueues_analytics_relevant_notifications() { assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); } +#[test] +fn realtime_handoff_tracks_only_marker_without_transcript() { + let (client, mut receiver) = client_with_receiver(); + client.track_notification(&ServerNotification::ThreadRealtimeItemAdded( + ThreadRealtimeItemAddedNotification { + thread_id: "thread-1".to_string(), + item: serde_json::json!({ + "type": "handoff_request", + "input_transcript": "private speech", + }), + }, + )); + let Ok(AnalyticsEventsQueueMessage::Fact(input)) = receiver.try_recv() else { + panic!("expected realtime handoff marker"); + }; + assert!(matches!( + *input, + AnalyticsFact::RealtimeHandoffRequested { thread_id } if thread_id == "thread-1" + )); + client.track_notification(&ServerNotification::ThreadRealtimeItemAdded( + ThreadRealtimeItemAddedNotification { + thread_id: "thread-1".to_string(), + item: serde_json::json!({"type": "input_transcript"}), + }, + )); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); +} + #[test] fn track_event_request_batches_only_isolates_accepted_line_fingerprint_events() { let batches = track_event_request_batches(vec![ diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index a79a7d18747b..7f62c974bfab 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -212,6 +212,7 @@ pub(crate) struct SkillInvocationEventParams { pub(crate) remote_plugin_id: Option, pub(crate) thread_id: Option, pub(crate) turn_id: Option, + pub(crate) voice_session_id: Option, pub(crate) invoke_type: Option, pub(crate) model_slug: Option, } @@ -860,6 +861,7 @@ pub(crate) struct CodexMcpToolCallEventParams { pub(crate) mcp_error_present: bool, pub(crate) plugin_id: Option, pub(crate) connector_id: Option, + pub(crate) voice_session_id: Option, pub(crate) elicitation_type: Option, } @@ -973,6 +975,7 @@ pub(crate) struct CodexAppMentionedEventRequest { pub(crate) struct CodexAppUsedMetadata { #[serde(flatten)] pub(crate) app: CodexAppMetadata, + pub(crate) voice_session_id: Option, pub(crate) elicitation_type: Option, } @@ -1065,6 +1068,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) session_id: String, pub(crate) turn_id: String, + pub(crate) voice_session_id: Option, pub(crate) root_turn_id: Option, pub(crate) turn_trigger: Option, pub(crate) codex_turn_source: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 05df3bc44c97..c86cad09d753 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -573,6 +573,9 @@ pub(crate) enum AnalyticsFact { completed_at_ms: u64, request_id: RequestId, }, + RealtimeHandoffRequested { + thread_id: String, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index da3452cad895..7fee1ccc5c7a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -227,6 +227,9 @@ struct ThreadAnalyticsState { connection_id: Option, metadata: Option, originator: Option, + active_voice_session_id: Option, + active_turn_id: Option, + pending_voice_handoffs: VecDeque>, } impl ThreadAnalyticsState { @@ -428,6 +431,7 @@ struct CompletedTurnState { struct TurnState { connection_id: Option, thread_id: Option, + voice_session_id: Option, num_input_images: Option, image_preparations: Vec, resolved_config: Option, @@ -609,6 +613,16 @@ impl AnalyticsReducer { } => { self.ingest_server_request_aborted(completed_at_ms, request_id, out); } + AnalyticsFact::RealtimeHandoffRequested { thread_id } => { + let thread = self.threads.entry(thread_id).or_default(); + // ThreadRealtimeStarted already attributes an active turn; only a new turn + // needs the handoff queued after the realtime session closes. + if thread.active_turn_id.is_none() { + thread + .pending_voice_handoffs + .push_back(thread.active_voice_session_id.clone()); + } + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::ArtifactOperation(input) => { self.ingest_artifact_operation(input, out); @@ -1374,6 +1388,10 @@ impl AnalyticsReducer { event_params: SkillInvocationEventParams { thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), + voice_session_id: self + .turns + .get(&tracking.turn_id) + .and_then(|turn| turn.voice_session_id.clone()), invoke_type: Some(invocation.invocation_type), model_slug: Some(tracking.model_slug.clone()), product_client_id: Some(tracking.product_client_id.clone()), @@ -1405,6 +1423,10 @@ impl AnalyticsReducer { } = input; let event_params = CodexAppUsedMetadata { app: codex_app_metadata(&tracking, app), + voice_session_id: self + .turns + .get(&tracking.turn_id) + .and_then(|turn| turn.voice_session_id.clone()), elicitation_type, }; out.push(TrackEventRequest::AppUsed(CodexAppUsedEventRequest { @@ -2009,7 +2031,7 @@ impl AnalyticsReducer { else { return; }; - if let Some(event) = tool_item_event(ToolItemEventInput { + if let Some(mut event) = tool_item_event(ToolItemEventInput { thread_id: ¬ification.thread_id, turn_id: ¬ification.turn_id, item: ¬ification.item, @@ -2022,6 +2044,12 @@ impl AnalyticsReducer { review_summary: self.item_review_summaries.get(&key), elicitation_type, }) { + if let TrackEventRequest::McpToolCall(mcp) = &mut event { + mcp.event_params.voice_session_id = self + .turns + .get(¬ification.turn_id) + .and_then(|turn| turn.voice_session_id.clone()); + } let root_turn_id = self .turns .get(¬ification.turn_id) @@ -2072,9 +2100,39 @@ impl AnalyticsReducer { self.code_mode_cells.remove(¬ification.thread_id); self.pending_mcp_tool_elicitations .retain(|(key, _)| key.thread_id != notification.thread_id); + if let Some(thread) = self.threads.get_mut(¬ification.thread_id) { + thread.active_voice_session_id = None; + thread.active_turn_id = None; + thread.pending_voice_handoffs.clear(); + } + } + ServerNotification::ThreadRealtimeStarted(notification) => { + let thread = self.threads.entry(notification.thread_id).or_default(); + thread.active_voice_session_id = + notification.realtime_session_id.filter(|id| !id.is_empty()); + if let Some(turn_id) = &thread.active_turn_id { + self.turns + .entry(turn_id.clone()) + .or_default() + .voice_session_id = thread.active_voice_session_id.clone(); + } + } + ServerNotification::ThreadRealtimeClosed(notification) => { + if let Some(thread) = self.threads.get_mut(¬ification.thread_id) { + thread.active_voice_session_id = None; + } } ServerNotification::TurnStarted(notification) => { + let voice_session_id = { + let thread = self.threads.entry(notification.thread_id).or_default(); + thread.active_turn_id = Some(notification.turn.id.clone()); + thread + .pending_voice_handoffs + .pop_front() + .unwrap_or_else(|| thread.active_voice_session_id.clone()) + }; let turn_state = self.turns.entry(notification.turn.id).or_default(); + turn_state.voice_session_id = voice_session_id; turn_state.started_at = notification .turn .started_at @@ -2086,6 +2144,11 @@ impl AnalyticsReducer { turn_state.latest_diff = Some(notification.diff); } ServerNotification::TurnCompleted(notification) => { + if let Some(thread) = self.threads.get_mut(¬ification.thread_id) + && thread.active_turn_id.as_deref() == Some(notification.turn.id.as_str()) + { + thread.active_turn_id = None; + } self.flush_pending_tool_events(¬ification.thread_id, ¬ification.turn.id, out); let turn_state = self.turns.entry(notification.turn.id.clone()).or_default(); turn_state.completed = Some(CompletedTurnState { @@ -2838,6 +2901,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { connector_id: app_context .as_ref() .map(|app_context| app_context.connector_id.clone()), + voice_session_id: None, elicitation_type, }, }, @@ -3594,6 +3658,7 @@ fn codex_turn_event_params( thread_id, session_id: thread_metadata.session_id.clone(), turn_id, + voice_session_id: turn_state.voice_session_id.clone(), root_turn_id: turn_metadata.root_turn_id(), turn_trigger: turn_metadata.turn_trigger(), codex_turn_source: turn_metadata.codex_turn_source(), From e412b93d08eb3d9d116373727349c4123baca16e Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Thu, 17 Sep 2026 00:25:25 +0000 Subject: [PATCH 002/373] Trim Guardian tests and tighten request layout assertions (#46063) ## What changed Remove standalone tests across Guardian review sessions, configuration, assessment schemas, transcripts, and trusted skill paths. Strengthen the existing request layout snapshot test to check exact transcript separators, the action header, and parent session ID placement before snapshot whitespace normalization. Concatenate user input text without inserting newlines so the assertions validate the actual request formatting. GitOrigin-RevId: 06d262a1a85b1a8401796958f300c894146d2e1b --- .../core/src/guardian/review_session_tests.rs | 46 ---- codex-rs/core/src/guardian/tests.rs | 200 +----------------- .../guardian-reviewer/src/assessment_tests.rs | 31 --- .../src/async_scorer/transcript_tests.rs | 90 -------- .../src/trusted_skills_tests.rs | 8 - 5 files changed, 5 insertions(+), 370 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session_tests.rs b/codex-rs/core/src/guardian/review_session_tests.rs index 255adf42766f..6220fd5a5809 100644 --- a/codex-rs/core/src/guardian/review_session_tests.rs +++ b/codex-rs/core/src/guardian/review_session_tests.rs @@ -695,17 +695,6 @@ async fn guardian_review_session_config_resolves_policy_and_template( ); } -#[test] -fn had_prior_review_context_tracks_prompt_mode() { - assert!(!had_prior_review_context(&GuardianPromptMode::Full)); - assert!(had_prior_review_context(&GuardianPromptMode::Delta { - cursor: GuardianTranscriptCursor { - parent_history_version: 7, - transcript_entry_count: 42, - } - })); -} - #[test] fn token_usage_delta_never_reports_negative_usage() { let start = TokenUsage { @@ -847,41 +836,6 @@ async fn run_review_removes_trunk_when_event_stream_is_broken() { assert!(manager.trunk().await.is_none()); } -#[tokio::test] -async fn wait_for_guardian_review_ignores_prior_turn_completion() { - let (review_session, tx_event, _rx_sub) = test_review_session().await; - tx_event - .send(turn_complete_event("prior-turn", Some("stale"), Some(9))) - .await - .expect("queue prior turn completion"); - tx_event - .send(turn_complete_event("current-turn", Some("fresh"), Some(42))) - .await - .expect("queue current turn completion"); - - let mut analytics_result = GuardianReviewAnalyticsResult::without_session(); - let codex_guardian_reviewer::ReviewTurnResult { - outcome, - disposition, - turn_completed, - } = wait_for_guardian_review( - &review_session, - "current-turn", - tokio::time::Instant::now() + Duration::from_secs(1), - /*external_cancel*/ None, - &mut analytics_result, - ) - .await; - - let GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) = outcome else { - panic!("expected current turn completion"); - }; - assert_eq!(last_agent_message.as_deref(), Some("fresh")); - assert_eq!(analytics_result.time_to_first_token_ms, Some(42)); - assert_eq!(disposition, SessionDisposition::Reusable); - assert!(turn_completed); -} - #[tokio::test] async fn wait_for_guardian_review_ignores_prior_turn_errors() { let (review_session, tx_event, _rx_sub) = test_review_session().await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 647b9d61735c..3d430ba72b7e 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -398,40 +398,6 @@ fn last_user_message_text_from_body(body: &serde_json::Value) -> String { .collect::() } -#[tokio::test(flavor = "current_thread")] -async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> anyhow::Result<()> { - let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; - seed_guardian_parent_history(&session, &turn).await; - - let prompt = build_guardian_prompt_items( - session.as_ref(), - Some("Sandbox denied outbound git push to github.com.".to_string()), - GuardianApprovalRequest::ExecCommand { - id: "shell-1".to_string(), - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - command: vec!["git".to_string(), "push".to_string()], - cwd: test_path_buf("/repo/codex-rs/core").abs().into(), - guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), - sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, - additional_permissions: None, - justification: Some("Need to push the reviewed docs fix.".to_string()), - tty: false, - }, - GuardianPromptMode::Full, - ) - .await?; - - let text = guardian_prompt_text(&prompt.context.into_user_inputs()?); - assert!(text.contains("whose request action you are assessing")); - assert!(text.contains(">>> TRANSCRIPT START\n")); - assert!(text.contains(">>> TRANSCRIPT END\n")); - assert!(text.contains("The Codex agent has requested the following action:\n")); - assert!(!text.contains("TRANSCRIPT DELTA")); - assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4); - - Ok(()) -} - #[tokio::test(flavor = "current_thread")] async fn build_guardian_prompt_prefers_retry_reason_over_approval_reason() -> anyhow::Result<()> { let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; @@ -896,43 +862,6 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() { ); } -#[test] -fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() { - let approval_text = - format!("{AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX}\n\nApproved action:\n{{}}"); - let items = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "ordinary developer context".to_string(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: approval_text.clone(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, - ]; - - let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); - - assert_eq!( - entries, - vec![ConversationTranscriptEntry { - kind: ConversationTranscriptEntryKind::Developer, - original_bytes: approval_text.len(), - text: approval_text, - }] - ); -} - #[test] fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { let mut items = vec![ @@ -1572,20 +1501,6 @@ async fn routes_approval_to_guardian_requires_guardian_reviewer() { assert!(routes_approval_to_guardian(&turn)); } -#[tokio::test] -async fn routes_approval_to_guardian_can_use_app_reviewer_override() { - let (_session, turn) = crate::session::tests::make_session_and_context().await; - - assert!(!routes_approval_to_guardian_with_reviewer( - &turn, - ApprovalsReviewer::User - )); - assert!(routes_approval_to_guardian_with_reviewer( - &turn, - ApprovalsReviewer::AutoReview - )); -} - #[tokio::test] async fn routes_approval_to_guardian_allows_granular_review_policy() { let (_session, mut turn) = crate::session::tests::make_session_and_context().await; @@ -2223,9 +2138,12 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() guardian_nested_tool_names, vec!["exec_command", "view_image", "write_stdin"] ); - let guardian_user_text = request.message_input_texts("user").join("\n"); + // Check exact separators before the snapshot normalizes trailing whitespace. + let guardian_user_text = request.message_input_texts("user").concat(); + assert!(guardian_user_text.contains(">>> TRANSCRIPT START\n")); + assert!(guardian_user_text.contains("The Codex agent has requested the following action:\n")); assert!(guardian_user_text.contains(&format!( - "Reviewed Codex session id: {}", + ">>> TRANSCRIPT END\nReviewed Codex session id: {}\n", fixed_guardian_parent_session_id() ))); assert!( @@ -2304,48 +2222,6 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() Ok(()) } -#[tokio::test] -async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Result<()> { - let (session, _) = crate::session::tests::make_session_and_context().await; - let prompt = build_guardian_prompt_items( - &session, - /*retry_reason*/ None, - GuardianApprovalRequest::ExecCommand { - id: "shell-1".to_string(), - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - command: vec!["git".to_string(), "status".to_string()], - cwd: test_path_buf("/repo").abs().into(), - guardian_cwd: native_guardian_cwd("/repo"), - sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, - additional_permissions: None, - justification: None, - tty: false, - }, - GuardianPromptMode::Full, - ) - .await?; - let prompt_text = prompt - .context - .into_user_inputs()? - .into_iter() - .map(|item| match item { - codex_protocol::user_input::UserInput::Text { text, .. } => text, - codex_protocol::user_input::UserInput::Image { .. } => String::new(), - _ => String::new(), - }) - .collect::(); - - assert!( - prompt_text.contains(&format!( - ">>> TRANSCRIPT END\nReviewed Codex session id: {}\n", - session.thread_id - )), - "guardian prompt should expose the parent session id immediately after the transcript end" - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -3986,25 +3862,6 @@ async fn guardian_review_session_config_allows_pinned_disabled_feature() { assert!(!guardian_config.include_apps_instructions); } -#[tokio::test] -async fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_slug() { - let mut parent_config = test_config().await; - parent_config.model = Some("configured-model".to_string()); - - let guardian_config = build_guardian_review_session_config_for_test( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummary::default(), - /*personality*/ None, - ResolvedModelMessages::bundled(), - ) - .expect("guardian config"); - - assert_eq!(guardian_config.model, Some("active-model".to_string())); -} - #[tokio::test] async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5_4() { let mut parent_config = test_config().await; @@ -4096,53 +3953,6 @@ async fn guardian_review_session_config_uses_requirements_guardian_policy_config ); } -#[tokio::test] -async fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override() -{ - let defaults = ResolvedModelMessages::bundled().auto_review(); - let codex_home = tempfile::tempdir().expect("create temp dir"); - let workspace = tempfile::tempdir().expect("create temp dir"); - let config_layer_stack = - ConfigLayerStack::new(Vec::new(), Default::default(), Default::default()) - .expect("config layer stack"); - let parent_config = Config::load_config_with_layer_stack( - LOCAL_FS.as_ref(), - ConfigToml::default(), - ConfigOverrides { - cwd: Some(workspace.path().to_path_buf()), - ..Default::default() - }, - codex_home.abs(), - config_layer_stack, - ) - .await - .expect("load config"); - - let guardian_config = build_guardian_review_session_config_for_test( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummary::default(), - /*personality*/ None, - ResolvedModelMessages::bundled(), - ) - .expect("guardian config"); - - assert_eq!(guardian_config.developer_instructions, None); - assert_eq!( - guardian_config.base_instructions, - Some( - GuardianPolicyInstructions::new( - defaults.policy, - defaults.policy_template, - guardian_output_contract_prompt(), - ) - .render() - ) - ); -} - // Keep the existing reviewer tests on the production decision path. async fn review_approval_request( session: &Arc, diff --git a/codex-rs/ext/guardian-reviewer/src/assessment_tests.rs b/codex-rs/ext/guardian-reviewer/src/assessment_tests.rs index f30113a7aa80..5cdd8e311e46 100644 --- a/codex-rs/ext/guardian-reviewer/src/assessment_tests.rs +++ b/codex-rs/ext/guardian-reviewer/src/assessment_tests.rs @@ -50,34 +50,3 @@ fn parse_guardian_assessment_treats_bare_deny_as_high_risk() { } ); } - -#[test] -fn guardian_output_schema_requires_only_outcome_and_allows_optional_details() { - let schema = guardian_output_schema(); - - assert_eq!( - schema, - serde_json::json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "risk_level": { - "type": "string", - "enum": ["low", "medium", "high", "critical"] - }, - "user_authorization": { - "type": "string", - "enum": ["unknown", "low", "medium", "high"] - }, - "outcome": { - "type": "string", - "enum": ["allow", "deny"] - }, - "rationale": { - "type": "string" - } - }, - "required": ["outcome"] - }) - ); -} diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs index b7f0bc72633e..f8ebb0aca674 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs @@ -2,7 +2,6 @@ use super::ContextInput; use codex_extension_api::ConversationHistorySnapshot; use codex_extension_api::ResponseItem; use codex_guardian_context::ContextTarget; -use codex_guardian_context::truncate_text as truncate_entry; use codex_protocol::AgentPath; use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::ContentItem; @@ -1029,84 +1028,6 @@ fn transcript_truncates_tool_results_using_standard_budget() { ); } -#[test] -fn transcript_preserves_outputs_with_call_ids_or_explicit_names() { - let mut items = vec![ResponseItem::FunctionCallOutput { - id: None, - call_id: None, - name: Some("notifications".to_owned()), - namespace: Some("slack".to_owned()), - output: FunctionCallOutputPayload::from_text("new message".to_owned()), - internal_chat_message_metadata_passthrough: None, - }]; - items.extend( - [ - (None, "anonymous output"), - (Some("missing-call"), "orphaned function output"), - ] - .map(|(call_id, text)| ResponseItem::FunctionCallOutput { - id: None, - call_id: call_id.map(str::to_string), - name: None, - namespace: None, - output: FunctionCallOutputPayload::from_text(text.to_string()), - internal_chat_message_metadata_passthrough: None, - }), - ); - - assert_eq!( - TranscriptConfig::default() - .build_context(ContextInput { - target: ContextTarget::Async, - history: &TestConversationHistory(&items), - root_conversation: &[], - trusted_user_answers: &[], - planned_action: None, - previous_reviews: None, - trusted_tool: None, - trusted_skill_paths: &[], - node_repl_images: None, - }) - .expect("collect transcript") - .transcript_entries(), - vec![ - "[1] tool slack.notifications result: new message\n", - "[2] tool result: orphaned function output\n", - ] - ); - - if let ResponseItem::FunctionCallOutput { output, .. } = &mut items[0] { - *output = FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputImage { - image: ImageReference::Inline { - image_url: "data:image/png;base64,image".to_owned(), - }, - detail: None, - }, - ]); - } - assert_eq!( - TranscriptConfig::default() - .build_context(ContextInput { - target: ContextTarget::Async, - history: &TestConversationHistory(&items), - root_conversation: &[], - trusted_user_answers: &[], - planned_action: None, - previous_reviews: None, - trusted_tool: None, - trusted_skill_paths: &[], - node_repl_images: None, - }) - .expect("collect transcript") - .transcript_entries(), - vec![ - "[1] tool slack.notifications result: [non-text output]\n", - "[2] tool result: orphaned function output\n", - ] - ); -} - #[test] fn configured_reasoning_counts_against_message_budget() { for (repeats, include_reasoning) in [(200, true), (1_000, false)] { @@ -1157,17 +1078,6 @@ fn configured_reasoning_counts_against_message_budget() { } } -#[test] -fn truncate_entry_preserves_prefix_suffix_and_utf8_boundaries() { - let text = format!("prefix é{}é suffix", "🦀".repeat(2_000)); - let truncated = truncate_entry(&text, /*max_tokens*/ 200); - - assert!(truncated.starts_with("prefix é")); - assert!(truncated.contains(") -> Vec { .expect("trusted skill evidence should remain valid JSON") } -#[test] -fn renders_verified_skill_paths() { - assert_eq!( - rendered_paths(vec!["/home/user/.codex/skills/demo/SKILL.md".to_owned()]), - vec!["/home/user/.codex/skills/demo/SKILL.md"], - ); -} - #[test] fn bounds_escaped_skill_paths_without_corrupting_json_or_utf8() { let paths = (0..16) From 08663cc91b0bdc4a2f6f3660da089c61d21a7652 Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Thu, 17 Sep 2026 00:25:26 +0000 Subject: [PATCH 003/373] Consolidate Guardian tests at shared policy and context boundaries (#46064) ## What changed - Move approval routing tests into `guardian-reviewer`, covering both reviewer choices across `UnlessTrusted`, `OnRequest`, `Granular`, and `Never` without host session setup. - Combine reviewer configuration checks into one test for isolation from parent customizations. - Cover filtering of ordinary developer messages while preserving manual approvals in `guardian-context`, removing the duplicate async scorer test. GitOrigin-RevId: b39ef21475e2f5d99d758f2d27615449b252dc65 --- .../core/src/guardian/review_session_tests.rs | 41 ----- codex-rs/core/src/guardian/tests.rs | 164 +++++------------- codex-rs/ext/guardian-reviewer/src/routing.rs | 4 + .../guardian-reviewer/src/routing_tests.rs | 30 ++++ .../src/async_scorer/transcript_tests.rs | 44 ----- .../guardian-context/src/transcript_tests.rs | 11 +- 6 files changed, 85 insertions(+), 209 deletions(-) create mode 100644 codex-rs/ext/guardian-reviewer/src/routing_tests.rs diff --git a/codex-rs/core/src/guardian/review_session_tests.rs b/codex-rs/core/src/guardian/review_session_tests.rs index 6220fd5a5809..9ec712aeffe0 100644 --- a/codex-rs/core/src/guardian/review_session_tests.rs +++ b/codex-rs/core/src/guardian/review_session_tests.rs @@ -570,47 +570,6 @@ async fn guardian_review_session_compact_scope_change_invalidates_cached_session assert_ne!(cached_reuse_key, next_reuse_key); } -#[tokio::test] -async fn guardian_review_session_config_disables_hooks() { - let mut parent_config = crate::config::test_config().await; - parent_config - .features - .enable(Feature::CodexHooks) - .expect("enable hooks on parent config"); - - let guardian_config = build_guardian_review_session_config( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummaryConfig::default(), - /*personality*/ None, - ResolvedModelMessages::bundled(), - ) - .expect("guardian config"); - - assert!(!guardian_config.features.enabled(Feature::CodexHooks)); -} - -#[tokio::test] -async fn guardian_review_session_config_disables_skill_instructions() { - let mut parent_config = crate::config::test_config().await; - parent_config.include_skill_instructions = true; - - let guardian_config = build_guardian_review_session_config( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummaryConfig::default(), - /*personality*/ None, - ResolvedModelMessages::bundled(), - ) - .expect("guardian config"); - - assert!(!guardian_config.include_skill_instructions); -} - #[test_case::test_case( Some("Use the managed Guardian policy."), Some("Configured Guardian template:\n{{ tenant_policy_config }}"), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 3d430ba72b7e..45287045b905 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -42,7 +42,6 @@ use codex_prompts::ResolvedModelMessages; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::NetworkApprovalProtocol; -use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; @@ -58,7 +57,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TurnCompleteEvent; @@ -1486,42 +1484,6 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning( assert!(warnings.is_empty()); } -#[tokio::test] -async fn routes_approval_to_guardian_requires_guardian_reviewer() { - let (_session, mut turn) = crate::session::tests::make_session_and_context().await; - let mut config = (*turn.config).clone(); - config.approvals_reviewer = ApprovalsReviewer::User; - turn.config = Arc::new(config.clone()); - - assert!(!routes_approval_to_guardian(&turn)); - - config.approvals_reviewer = ApprovalsReviewer::AutoReview; - turn.config = Arc::new(config); - - assert!(routes_approval_to_guardian(&turn)); -} - -#[tokio::test] -async fn routes_approval_to_guardian_allows_granular_review_policy() { - let (_session, mut turn) = crate::session::tests::make_session_and_context().await; - let mut config = (*turn.config).clone(); - config.approvals_reviewer = ApprovalsReviewer::AutoReview; - turn.config = Arc::new(config); - Arc::make_mut(&mut turn.config) - .permissions - .approval_policy - .set(AskForApproval::Granular(GranularApprovalConfig { - sandbox_approval: true, - rules: true, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - })) - .expect("test setup should allow updating approval policy"); - - assert!(routes_approval_to_guardian(&turn)); -} - #[test] fn build_guardian_transcript_reserves_separate_budget_for_tool_evidence() { let repeated = "signal ".repeat(8_000); @@ -3686,60 +3648,6 @@ async fn guardian_review_session_config_preserves_context_overrides_for_same_eff ); } -#[tokio::test] -async fn guardian_review_session_config_clears_parent_developer_instructions() { - let defaults = ResolvedModelMessages::bundled().auto_review(); - let mut parent_config = test_config().await; - parent_config.developer_instructions = - Some("parent or managed config should not replace guardian policy".to_string()); - - let guardian_config = build_guardian_review_session_config_for_test( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummary::default(), - /*personality*/ None, - ResolvedModelMessages::bundled(), - ) - .expect("guardian config"); - - assert_eq!(guardian_config.developer_instructions, None); - assert_eq!( - guardian_config.base_instructions, - Some( - GuardianPolicyInstructions::new( - defaults.policy, - defaults.policy_template, - guardian_output_contract_prompt(), - ) - .render() - ) - ); -} - -#[tokio::test] -async fn guardian_review_session_config_clears_legacy_notify() { - let mut parent_config = test_config().await; - parent_config.notify = Some(vec![ - "/path/to/notify".to_string(), - "turn-ended".to_string(), - ]); - - let guardian_config = build_guardian_review_session_config_for_test( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummary::default(), - /*personality*/ None, - ResolvedModelMessages::bundled(), - ) - .expect("guardian config"); - - assert_eq!(guardian_config.notify, None); -} - #[tokio::test] async fn guardian_review_session_config_uses_live_network_proxy_state() { let mut parent_config = test_config().await; @@ -3788,7 +3696,8 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { } #[tokio::test] -async fn guardian_review_session_config_disables_mcp_apps_plugins_memories_and_guardian_v2() { +async fn guardian_review_session_config_isolates_parent_customizations() { + let defaults = ResolvedModelMessages::bundled().auto_review(); let mut parent_config = test_config().await; let server: McpServerConfig = toml::from_str("command = \"docs-server\"").expect("deserialize MCP server"); @@ -3796,21 +3705,28 @@ async fn guardian_review_session_config_disables_mcp_apps_plugins_memories_and_g .mcp_servers .set(HashMap::from([("docs".to_string(), server)])) .expect("parent MCP servers are configurable"); - parent_config - .features - .enable(Feature::Apps) - .expect("apps feature is configurable"); - parent_config - .features - .enable(Feature::Plugins) - .expect("plugins feature is configurable"); - parent_config - .features - .enable(Feature::GuardianV2) - .expect("guardian v2 feature is configurable"); + let disabled_features = [ + Feature::Apps, + Feature::Plugins, + Feature::GuardianV2, + Feature::CodexHooks, + ]; + for feature in disabled_features { + parent_config + .features + .enable(feature) + .expect("enable feature on parent config"); + } parent_config.include_apps_instructions = true; + parent_config.include_skill_instructions = true; parent_config.memories.use_memories = true; parent_config.memories.dedicated_tools = true; + parent_config.developer_instructions = + Some("parent or managed config should not replace guardian policy".to_string()); + parent_config.notify = Some(vec![ + "/path/to/notify".to_string(), + "turn-ended".to_string(), + ]); let guardian_config = build_guardian_review_session_config_for_test( crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), @@ -3824,12 +3740,29 @@ async fn guardian_review_session_config_disables_mcp_apps_plugins_memories_and_g .expect("guardian config"); assert!(guardian_config.mcp_servers.get().is_empty()); - assert!(!guardian_config.features.enabled(Feature::Apps)); - assert!(!guardian_config.features.enabled(Feature::Plugins)); - assert!(!guardian_config.features.enabled(Feature::GuardianV2)); + for feature in disabled_features { + assert!( + !guardian_config.features.enabled(feature), + "{feature:?} should be disabled in the reviewer" + ); + } assert!(!guardian_config.include_apps_instructions); + assert!(!guardian_config.include_skill_instructions); assert!(!guardian_config.memories.use_memories); assert!(!guardian_config.memories.dedicated_tools); + assert_eq!(guardian_config.notify, None); + assert_eq!(guardian_config.developer_instructions, None); + assert_eq!( + guardian_config.base_instructions, + Some( + GuardianPolicyInstructions::new( + defaults.policy, + defaults.policy_template, + guardian_output_contract_prompt(), + ) + .render() + ) + ); } #[tokio::test] @@ -3978,18 +3911,3 @@ async fn review_approval_request( .await .expect("Guardian should handle the request") } - -/// Whether this turn should route allowed approval prompts through the guardian -/// reviewer instead of surfacing them to the user. ARC may still block actions -/// earlier in the flow. -fn routes_approval_to_guardian(turn: &crate::session::turn_context::TurnContext) -> bool { - routes_approval_to_guardian_with_reviewer(turn, turn.config.approvals_reviewer) -} - -/// Whether an approval with its own reviewer selection should be routed through guardian. -fn routes_approval_to_guardian_with_reviewer( - turn: &crate::session::turn_context::TurnContext, - approvals_reviewer: ApprovalsReviewer, -) -> bool { - routes_approval_policy_to_guardian(turn.approval_policy(), approvals_reviewer) -} diff --git a/codex-rs/ext/guardian-reviewer/src/routing.rs b/codex-rs/ext/guardian-reviewer/src/routing.rs index 9dfbfd2b06b3..87a872f211d4 100644 --- a/codex-rs/ext/guardian-reviewer/src/routing.rs +++ b/codex-rs/ext/guardian-reviewer/src/routing.rs @@ -183,3 +183,7 @@ impl ReviewRequest<'_, H> { ReviewDecision::Approved } } + +#[cfg(test)] +#[path = "routing_tests.rs"] +mod tests; diff --git a/codex-rs/ext/guardian-reviewer/src/routing_tests.rs b/codex-rs/ext/guardian-reviewer/src/routing_tests.rs new file mode 100644 index 000000000000..3a59454bb932 --- /dev/null +++ b/codex-rs/ext/guardian-reviewer/src/routing_tests.rs @@ -0,0 +1,30 @@ +//! Approval routing coverage at the policy boundary, without host session setup. + +use super::routes_approval_policy_to_guardian; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; +use pretty_assertions::assert_eq; + +#[test] +fn approval_routing_depends_on_policy_and_reviewer() { + for (policy, expected) in [ + (AskForApproval::UnlessTrusted, [false, false]), + (AskForApproval::OnRequest, [false, true]), + ( + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + [false, true], + ), + (AskForApproval::Never, [false, false]), + ] { + let actual = [ApprovalsReviewer::User, ApprovalsReviewer::AutoReview] + .map(|reviewer| routes_approval_policy_to_guardian(policy, reviewer)); + assert_eq!(actual, expected, "approval policy: {policy:?}"); + } +} diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs index f8ebb0aca674..ea586bc52a63 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs @@ -1078,50 +1078,6 @@ fn configured_reasoning_counts_against_message_budget() { } } -#[test] -fn transcript_keeps_only_manual_approval_developer_messages() { - let approval_text = format!("{MANUAL_APPROVAL_DEVELOPER_PREFIX}\n\nApproved action:\n{{}}"); - let items = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "ordinary developer context".to_string(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: approval_text.clone(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, - ]; - - let transcript = TranscriptConfig::default() - .build_context(ContextInput { - target: ContextTarget::Async, - history: &TestConversationHistory(&items), - root_conversation: &[], - trusted_user_answers: &[], - planned_action: None, - previous_reviews: None, - trusted_tool: None, - trusted_skill_paths: &[], - node_repl_images: None, - }) - .expect("collect transcript") - .transcript_entries(); - assert_eq!( - transcript, - vec![format!("[1] developer: {approval_text}\n")] - ); -} - #[test] fn transcript_omits_media_payloads_and_keeps_readable_content() { let oversized_image = diff --git a/codex-rs/guardian-context/src/transcript_tests.rs b/codex-rs/guardian-context/src/transcript_tests.rs index 329fe158efd1..9e3adb362b7d 100644 --- a/codex-rs/guardian-context/src/transcript_tests.rs +++ b/codex-rs/guardian-context/src/transcript_tests.rs @@ -42,7 +42,7 @@ fn entry(kind: ConversationTranscriptEntryKind, text: &str) -> ConversationTrans } #[test] -fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() { +fn registered_transcript_filters_roles_and_preserves_node_repl_tool_attribution() { let approved_action = format!( "{MANUAL_APPROVAL_DEVELOPER_PREFIX}\nApproved action: {}", "exact action ".repeat(/*n*/ 1_000) @@ -57,6 +57,15 @@ fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() phase: None, internal_chat_message_metadata_passthrough: None, }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "ordinary developer context".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, ResponseItem::Message { id: None, role: "developer".to_string(), From 5e636ea760d1c821a9162af652a14225fdbd8a0f Mon Sep 17 00:00:00 2001 From: Krish Chainani Date: Thu, 17 Sep 2026 00:38:14 +0000 Subject: [PATCH 004/373] Route prepared images through the attachment store (#46065) ## What changed Upload prepared images from messages and tool outputs through the session's `AttachmentStore` before recording history. Use the returned inline bytes or file reference, and fall back to the prepared inline image if upload fails. Keep history replay on `InlineAttachmentStore` so it does not upload recorded images, and preserve existing file references. Reuse image resizing directly for guardian review comparisons without uploading. ## Testing Add coverage for upload failure preserving resized inline images. Extend rollout coverage to verify that only new images are uploaded, uploaded bytes have the expected dimensions, and file references and resize notices survive replay. GitOrigin-RevId: e9966297c3579ef1e52376f0235fe3a7e3ec7ecc --- codex-rs/Cargo.lock | 1 + codex-rs/attachment-store/Cargo.toml | 1 + codex-rs/attachment-store/src/lib.rs | 2 + codex-rs/core/src/guardian/review_session.rs | 41 ++-- codex-rs/core/src/image_preparation.rs | 208 +++++++++++++------ codex-rs/core/src/image_preparation_tests.rs | 106 ++++++++-- codex-rs/core/src/session/inject.rs | 12 +- codex-rs/core/src/session/mod.rs | 36 ++-- codex-rs/core/src/session/tests.rs | 12 +- codex-rs/core/tests/suite/image_rollout.rs | 85 +++++++- 10 files changed, 370 insertions(+), 134 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4cf1efabc7bf..77470f0ae150 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2489,6 +2489,7 @@ dependencies = [ "pretty_assertions", "serde", "tokio", + "tracing", ] [[package]] diff --git a/codex-rs/attachment-store/Cargo.toml b/codex-rs/attachment-store/Cargo.toml index f2a1f4e5b9b0..dd66412fa162 100644 --- a/codex-rs/attachment-store/Cargo.toml +++ b/codex-rs/attachment-store/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] serde = { workspace = true, features = ["derive"] } +tracing = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/attachment-store/src/lib.rs b/codex-rs/attachment-store/src/lib.rs index df586a9fd164..c39374e82903 100644 --- a/codex-rs/attachment-store/src/lib.rs +++ b/codex-rs/attachment-store/src/lib.rs @@ -158,6 +158,7 @@ impl fmt::Debug for UploadResult { pub struct InlineAttachmentStore; impl AttachmentStore for InlineAttachmentStore { + #[tracing::instrument(level = "trace", skip_all)] fn upload(&self, request: UploadRequest) -> UploadFuture<'_> { Box::pin(async move { Ok(UploadResult::Inline { @@ -166,6 +167,7 @@ impl AttachmentStore for InlineAttachmentStore { }) } + #[tracing::instrument(level = "trace", skip_all)] fn resolve<'a>(&'a self, request: ResolveRequest<'a>) -> ResolveFuture<'a> { let file_id = request.file_id; Box::pin(async move { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index fa7c59eb66a9..7179aa3175eb 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -12,6 +12,7 @@ pub(crate) use setup::run_guardian_review_session; mod context_policy; use context_policy::ReviewContextPolicy; +use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -68,8 +69,7 @@ use crate::context::GuardianNodeReplPolicy; use crate::context_manager::ContextManager; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::image_preparation::ImagePreparationMode; -use crate::image_preparation::ImageResizeNoticeMode; -use crate::image_preparation::prepare_response_items; +use crate::image_preparation::resize_image; use crate::image_preparation::unified_image_budget_enabled; use crate::session::SessionIo; use crate::session::session::Session; @@ -80,7 +80,6 @@ use codex_model_provider_info::ModelProviderInfo; use codex_protocol::turn_input::TurnInputMode; use codex_protocol::turn_input::TurnInputRequest; use codex_protocol::turn_input::TurnInputSubmission; -use codex_protocol::user_input::UserInput; use codex_thread_store::PersistContext; use codex_tools::normalize_output_image_detail; use codex_utils_path_uri::PathUri; @@ -486,7 +485,8 @@ async fn run_review_on_session( let parent_history = params.parent_history.conversation_history_snapshot(); let history = if GuardianContextMode::from_history(parent_history.as_ref()) - == GuardianContextMode::ThreadOwned { + == GuardianContextMode::ThreadOwned + { parent_history } else { params.parent_session.conversation_history_snapshot().await @@ -515,7 +515,10 @@ async fn run_review_on_session( _ => &[], }) .filter_map(|item| match item { - ContentItem::InputImage { image: ImageReference::Inline { image_url }, .. } => Some(image_url.as_str()), + ContentItem::InputImage { + image: ImageReference::Inline { image_url }, + .. + } => Some(image_url.as_str()), _ => None, }) .collect::>(); @@ -547,27 +550,15 @@ async fn run_review_on_session( Some(ImageDetail::Low) => Some(ImageDetail::High), detail => detail, }; - let mut prepared = vec![ - ResponseInputItem::from(vec![UserInput::Image { - image: ImageReference::Inline { - image_url: image_url.to_owned(), - }, - detail: *detail, - }]) - .into(), - ]; - prepare_response_items( - &mut prepared, - mode, - ImageResizeNoticeMode::Disabled, - ); - let Some(ResponseItem::Message { content, .. }) = prepared.first() else { - return false; + let prepared_image_url = match resize_image(image_url, detail, mode) { + Ok(Some(prepared)) => Cow::Owned(prepared.into_data_url()), + Ok(None) => Cow::Borrowed(image_url), + Err(error) => { + warn!(%error, "failed to prepare guardian review image"); + return false; + } }; - content.iter().any(|item| { - matches!(item, ContentItem::InputImage { image: ImageReference::Inline { image_url }, .. } - if !reviewer_image_urls.contains(image_url.as_str())) - }) + !reviewer_image_urls.contains(prepared_image_url.as_ref()) }); let prompt: ResponseItem = ResponseInputItem::from(prompt_items.context.clone().into_user_inputs()?) diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index 97098705cb94..ce96d1977700 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -6,6 +6,9 @@ use crate::context::ResizedImage; use crate::original_image_detail::can_request_original_image_detail; use codex_analytics::ImageDetailSetting; use codex_analytics::ImagePreparationMetadata; +use codex_attachment_store::AttachmentStore; +use codex_attachment_store::UploadRequest; +use codex_attachment_store::UploadResult; use codex_context_fragments::AnnotatedContent; use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; @@ -18,8 +21,10 @@ use codex_protocol::models::ImageDetail; use codex_protocol::models::ImageReference; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; +use codex_utils_image::EncodedImage; use codex_utils_image::ImageProcessingError; use codex_utils_image::PromptImageMode; +use codex_utils_image::data_url_from_bytes; use codex_utils_image::load_data_url_for_prompt; use tracing::warn; @@ -65,8 +70,32 @@ struct PreparedImageResize { prepared_height: u32, } +pub(crate) struct PreparedInlineImage { + encoded: EncodedImage, + effective_detail: ImageDetailSetting, + resize: Option, +} + +impl PreparedInlineImage { + pub(crate) fn into_data_url(self) -> String { + self.encoded.into_data_url() + } + + fn metadata(&self, origin: ImageOrigin<'_>) -> ImagePreparationMetadata { + ImagePreparationMetadata { + message_role: origin.message_role.map(str::to_string), + item_id: origin.item_id.map(str::to_string), + effective_detail: self.effective_detail, + source_width: self.encoded.source_width, + source_height: self.encoded.source_height, + prepared_width: self.encoded.width, + prepared_height: self.encoded.height, + } + } +} + #[derive(Debug, thiserror::Error)] -enum ImagePreparationError { +pub(crate) enum ImagePreparationError { #[error("remote image URLs are not supported")] RemoteUrlUnsupported, #[error("image detail `low` is not supported")] @@ -88,33 +117,14 @@ impl ImagePreparationError { } } -pub(crate) fn prepare_response_items( +pub(crate) async fn prepare_response_items( items: &mut Vec, mode: ImagePreparationMode, resize_notice_mode: ImageResizeNoticeMode, + image_store: &dyn AttachmentStore, ) -> Vec { let mut metadata = Vec::new(); let mut prepared_items = Vec::with_capacity(items.len()); - let prepare_tool_output = - |output: &mut FunctionCallOutputPayload, - item_id: Option<&str>, - metadata: &mut Vec| { - output.content_items_mut().and_then(|content| { - let resized_images = prepare_tool_output_content( - content, - ImageOrigin { - message_role: None, - item_id, - }, - resize_notice_mode, - metadata, - mode, - ); - (!resized_images.is_empty()).then(|| { - ImageResizeNotice::new(ImageResizeNoticeSource::ToolOutput, resized_images) - }) - }) - }; for mut item in std::mem::take(items) { let mut annotated_content = to_annotated_content(&mut item); let resize_notice = match &mut item { @@ -135,7 +145,9 @@ pub(crate) fn prepare_response_items( }, &mut metadata, mode, - ); + image_store, + ) + .await; let _ = set_annotated_content(&mut item, content); (!resized_images.is_empty()).then(|| { ImageResizeNotice::new(ImageResizeNoticeSource::UserMessage, resized_images) @@ -143,10 +155,30 @@ pub(crate) fn prepare_response_items( } ResponseItem::FunctionCallOutput { call_id, output, .. - } => prepare_tool_output(output, call_id.as_deref(), &mut metadata), + } => { + prepare_tool_output( + output, + call_id.as_deref(), + resize_notice_mode, + &mut metadata, + mode, + image_store, + ) + .await + } ResponseItem::CustomToolCallOutput { call_id, output, .. - } => prepare_tool_output(output, Some(call_id.as_str()), &mut metadata), + } => { + prepare_tool_output( + output, + Some(call_id.as_str()), + resize_notice_mode, + &mut metadata, + mode, + image_store, + ) + .await + } ResponseItem::AdditionalTools { .. } | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } @@ -172,15 +204,47 @@ pub(crate) fn prepare_response_items( metadata } -fn prepare_message_content( +async fn prepare_tool_output( + output: &mut FunctionCallOutputPayload, + item_id: Option<&str>, + resize_notice_mode: ImageResizeNoticeMode, + metadata: &mut Vec, + mode: ImagePreparationMode, + image_store: &dyn AttachmentStore, +) -> Option { + let content = output.content_items_mut()?; + let resized_images = prepare_tool_output_content( + content, + ImageOrigin { + message_role: None, + item_id, + }, + resize_notice_mode, + metadata, + mode, + image_store, + ) + .await; + if resized_images.is_empty() { + None + } else { + Some(ImageResizeNotice::new( + ImageResizeNoticeSource::ToolOutput, + resized_images, + )) + } +} + +async fn prepare_message_content( items: &mut [AnnotatedContent], origin: ImageOrigin<'_>, resize_notice_mode: ImageResizeNoticeMode, metadata: &mut Vec, mode: ImagePreparationMode, + image_store: &dyn AttachmentStore, ) -> Vec { - // TODO(kc) Prepare file-backed images once Core has an explicit resolution contract. Until - // then they pass through while retaining their positions in resize notices. + // File-backed images are already prepared and pass through while retaining their positions in + // resize notices. let image_count = items .iter() .filter(|item| matches!(item.content(), ContentItem::InputImage { .. })) @@ -188,15 +252,9 @@ fn prepare_message_content( let mut image_number = 0; let mut resized_images = Vec::new(); for item in items { - if matches!(item.content(), ContentItem::InputImage { .. }) { + if let ContentItem::InputImage { image, detail } = item.content_mut() { image_number += 1; - } - if let ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } = item.content_mut() - { - match prepare_image(image_url, detail, origin, metadata, mode) { + match prepare_image(image, detail, origin, metadata, mode, image_store).await { Ok(Some(resize)) if resize_notice_mode == ImageResizeNoticeMode::Enabled => { resized_images.push(ResizedImage { image_number, @@ -221,12 +279,13 @@ fn prepare_message_content( resized_images } -fn prepare_tool_output_content( +async fn prepare_tool_output_content( items: &mut [FunctionCallOutputContentItem], origin: ImageOrigin<'_>, resize_notice_mode: ImageResizeNoticeMode, metadata: &mut Vec, mode: ImagePreparationMode, + image_store: &dyn AttachmentStore, ) -> Vec { let image_count = items .iter() @@ -235,15 +294,9 @@ fn prepare_tool_output_content( let mut image_number = 0; let mut resized_images = Vec::new(); for item in items { - if matches!(item, FunctionCallOutputContentItem::InputImage { .. }) { + if let FunctionCallOutputContentItem::InputImage { image, detail } = item { image_number += 1; - } - if let FunctionCallOutputContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } = item - { - match prepare_image(image_url, detail, origin, metadata, mode) { + match prepare_image(image, detail, origin, metadata, mode, image_store).await { Ok(Some(resize)) if resize_notice_mode == ImageResizeNoticeMode::Enabled => { resized_images.push(ResizedImage { image_number, @@ -279,13 +332,46 @@ fn is_data_url(image_url: &str) -> bool { .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:")) } -fn prepare_image( - image_url: &mut String, +async fn prepare_image( + image: &mut ImageReference, detail: &mut Option, origin: ImageOrigin<'_>, metadata: &mut Vec, mode: ImagePreparationMode, + image_store: &dyn AttachmentStore, ) -> Result, ImagePreparationError> { + let ImageReference::Inline { image_url } = image else { + return Ok(None); + }; + let Some(prepared) = resize_image(image_url, detail, mode)? else { + return Ok(None); + }; + metadata.push(prepared.metadata(origin)); + let resize = prepared.resize; + let request = UploadRequest { + file_name: None, + data: prepared.encoded.bytes.to_vec(), + }; + *image = match image_store.upload(request).await { + Ok(UploadResult::Inline { bytes }) => ImageReference::Inline { + image_url: data_url_from_bytes(&prepared.encoded.mime, &bytes), + }, + Ok(UploadResult::File { file_id }) => ImageReference::File { file_id }, + Err(error) => { + warn!(kind = ?error.kind(), "failed to upload prepared image"); + ImageReference::Inline { + image_url: prepared.into_data_url(), + } + } + }; + Ok(resize) +} + +pub(crate) fn resize_image( + image_url: &str, + detail: &mut Option, + mode: ImagePreparationMode, +) -> Result, ImagePreparationError> { if is_remote_image_url(image_url) { return Err(ImagePreparationError::RemoteUrlUnsupported); } @@ -309,30 +395,24 @@ fn prepare_image( Some(ImageDetail::Low) => return Err(ImagePreparationError::UnsupportedLowDetail), }, }; - let image = load_data_url_for_prompt(image_url, image_mode)?; - metadata.push(ImagePreparationMetadata { - message_role: origin.message_role.map(str::to_string), - item_id: origin.item_id.map(str::to_string), - effective_detail, - source_width: image.source_width, - source_height: image.source_height, - prepared_width: image.width, - prepared_height: image.height, - }); - let resize = ((image.source_width, image.source_height) != (image.width, image.height)) + let encoded = load_data_url_for_prompt(image_url, image_mode)?; + let resize = ((encoded.source_width, encoded.source_height) != (encoded.width, encoded.height)) .then_some(PreparedImageResize { - source_width: image.source_width, - source_height: image.source_height, - prepared_width: image.width, - prepared_height: image.height, + source_width: encoded.source_width, + source_height: encoded.source_height, + prepared_width: encoded.width, + prepared_height: encoded.height, }); - *image_url = image.into_data_url(); if mode == ImagePreparationMode::UnifiedBudget { // Preserve accurate context-window accounting while older transports still require an // image detail field. Responses Lite removes this compatibility hint before sending. *detail = Some(ImageDetail::Original); } - Ok(resize) + Ok(Some(PreparedInlineImage { + encoded, + effective_detail, + resize, + })) } #[cfg(test)] diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index ba2324358d21..f083fc4034a7 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -2,6 +2,12 @@ use std::io::Cursor; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_attachment_store::AttachmentStoreError; +use codex_attachment_store::AttachmentStoreErrorKind; +use codex_attachment_store::InlineAttachmentStore; +use codex_attachment_store::ResolveFuture; +use codex_attachment_store::ResolveRequest; +use codex_attachment_store::UploadFuture; use codex_protocol::models::ContentItemKind; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; @@ -17,6 +23,29 @@ use pretty_assertions::assert_eq; use super::*; +struct FailingAttachmentStore; + +impl AttachmentStore for FailingAttachmentStore { + fn upload(&self, _request: UploadRequest) -> UploadFuture<'_> { + Box::pin(async { + Err(AttachmentStoreError::new( + AttachmentStoreErrorKind::Backend, + "upload failed", + )) + }) + } + + fn resolve<'a>(&'a self, request: ResolveRequest<'a>) -> ResolveFuture<'a> { + let file_id = request.file_id; + Box::pin(async move { + Err(AttachmentStoreError::new( + AttachmentStoreErrorKind::NotFound, + format!("attachment `{file_id}` was not found"), + )) + }) + } +} + fn png_data_url(width: u32, height: u32) -> (String, Vec) { let image = ImageBuffer::from_pixel(width, height, Rgba([10u8, 20, 30, 255])); let mut encoded = Cursor::new(Vec::new()); @@ -34,8 +63,8 @@ fn decoded_image(image_url: &str) -> (Vec, DynamicImage) { (bytes, image) } -#[test] -fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { +#[tokio::test(flavor = "multi_thread")] +async fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { let (data_url, original_bytes) = png_data_url(/*width*/ 64, /*height*/ 32); let mut items = vec![ResponseItem::Message { id: None, @@ -74,7 +103,9 @@ fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { &mut items, ImagePreparationMode::DetailBased, ImageResizeNoticeMode::Disabled, - ); + &InlineAttachmentStore, + ) + .await; let ResponseItem::Message { content, .. } = &items[0] else { panic!("expected message"); @@ -112,8 +143,8 @@ fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { ); } -#[test] -fn detail_policies_apply_the_expected_budgets() { +#[tokio::test(flavor = "multi_thread")] +async fn detail_policies_apply_the_expected_budgets() { for (detail, effective_detail, input_dimensions, expected_dimensions) in [ ( Some(ImageDetail::High), @@ -157,7 +188,9 @@ fn detail_policies_apply_the_expected_budgets() { &mut items, ImagePreparationMode::DetailBased, ImageResizeNoticeMode::Disabled, - ); + &InlineAttachmentStore, + ) + .await; let ResponseItem::Message { content, .. } = &items[0] else { panic!("expected message"); @@ -187,8 +220,8 @@ fn detail_policies_apply_the_expected_budgets() { } } -#[test] -fn preparation_reports_tool_output_item_id() { +#[tokio::test(flavor = "multi_thread")] +async fn preparation_reports_tool_output_item_id() { let call_id = "call-image"; let (image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32); let mut items = vec![ResponseItem::FunctionCallOutput { @@ -208,7 +241,9 @@ fn preparation_reports_tool_output_item_id() { &mut items, ImagePreparationMode::DetailBased, ImageResizeNoticeMode::Disabled, - ); + &InlineAttachmentStore, + ) + .await; assert_eq!( metadata, @@ -224,9 +259,46 @@ fn preparation_reports_tool_output_item_id() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn upload_failure_keeps_resized_image_inline() { + let (image_url, _) = png_data_url(/*width*/ 2048, /*height*/ 2048); + let mut items = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image: ImageReference::Inline { image_url }, + detail: Some(ImageDetail::High), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }]; + + prepare_response_items( + &mut items, + ImagePreparationMode::DetailBased, + ImageResizeNoticeMode::Disabled, + &FailingAttachmentStore, + ) + .await; + + let ResponseItem::Message { content, .. } = &items[0] else { + panic!("expected message"); + }; + let [ + ContentItem::InputImage { + image: ImageReference::Inline { image_url }, + .. + }, + ] = content.as_slice() + else { + panic!("expected inline image"); + }; + assert_eq!(decoded_image(image_url).1.dimensions(), (1600, 1600)); +} + /// File-backed images retain their sequence positions even though preparation skips them. -#[test] -fn resize_notices_count_file_backed_images_and_skip_failed_images() { +#[tokio::test(flavor = "multi_thread")] +async fn resize_notices_count_file_backed_images_and_skip_failed_images() { let (large_image_url, _) = png_data_url(/*width*/ 2048, /*height*/ 2048); let (small_image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32); let mut items = vec![ @@ -305,7 +377,9 @@ fn resize_notices_count_file_backed_images_and_skip_failed_images() { &mut items, ImagePreparationMode::DetailBased, ImageResizeNoticeMode::Enabled, - ); + &InlineAttachmentStore, + ) + .await; let expected_user_notice = concat!( "\n", "Image 4 of 4 in the preceding user message was resized from 2048x2048 to 1600x1600 pixels.\n", @@ -443,8 +517,8 @@ fn resize_notices_count_file_backed_images_and_skip_failed_images() { ); } -#[test] -fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { +#[tokio::test(flavor = "multi_thread")] +async fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { let (valid_image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32); let expected_valid_image_url = valid_image_url.clone(); let mut items = vec![ResponseItem::CustomToolCallOutput { @@ -490,7 +564,9 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { &mut items, ImagePreparationMode::DetailBased, ImageResizeNoticeMode::Disabled, - ); + &InlineAttachmentStore, + ) + .await; assert_eq!( items, diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 50b306a77482..8264d238865d 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -127,11 +127,13 @@ impl Session { let mut annotated_items = Vec::with_capacity(items.len()); let mut image_preparations = Vec::new(); for envelope in items { - let (prepared_items, prepared_images) = self.prepare_conversation_items_for_history( - turn_context, - model_info, - std::slice::from_ref(&envelope.item), - ); + let (prepared_items, prepared_images) = self + .prepare_conversation_items_for_history( + turn_context, + model_info, + std::slice::from_ref(&envelope.item), + ) + .await; image_preparations.extend(prepared_images); let mut metadata = envelope.metadata; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8a1679bddc86..c242c83cd4d6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -64,6 +64,7 @@ use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnCodexErrorFact; use codex_async_utils::OrCancelExt; use codex_attachment_store::AttachmentStore; +use codex_attachment_store::InlineAttachmentStore; use codex_connectors::connector_runtime_context_key; use codex_context_fragments::RenderedFragment; use codex_exec_server::Environment; @@ -1706,11 +1707,16 @@ impl Session { .into_iter() .map(|envelope| (envelope.item, envelope.metadata)) .unzip(); - let _ = prepare_image_response_items( + // Replay must not upload or migrate recorded history. The inline store returns prepared + // inline bytes, while existing file references bypass preparation and remain unchanged. + // Bound replay future size now that image preparation can await storage. + let _ = Box::pin(prepare_image_response_items( &mut prepared_history, ImagePreparationMode::DetailBased, ImageResizeNoticeMode::Disabled, - ); + &InlineAttachmentStore, + )) + .await; prepare_audio_response_items(&mut prepared_history); assert_eq!( prepared_history.len(), @@ -3476,7 +3482,7 @@ impl Session { } /// Prepares media using the originating model and preserves existing item identity. - pub(crate) fn prepare_conversation_items_for_history<'a>( + pub(crate) async fn prepare_conversation_items_for_history<'a>( &self, turn_context: &TurnContext, model_info: &ModelInfo, @@ -3498,11 +3504,14 @@ impl Session { } else { ImageResizeNoticeMode::Disabled }; - let image_preparations = prepare_image_response_items( + // Keep nested image-upload futures out of every caller's future frame. + let image_preparations = Box::pin(prepare_image_response_items( &mut items, image_preparation_mode, image_resize_notice_mode, - ); + self.services.image_store.as_ref(), + )) + .await; prepare_audio_response_items(&mut items); // Most response items get their passthrough turn ID at the durable history boundary. for item in &mut items { @@ -3591,8 +3600,9 @@ impl Session { model_info: &ModelInfo, items: &[ResponseItem], ) { - let (items, image_preparations) = - self.prepare_conversation_items_for_history(turn_context, model_info, items); + let (items, image_preparations) = self + .prepare_conversation_items_for_history(turn_context, model_info, items) + .await; let items = items .into_owned() .into_iter() @@ -3911,11 +3921,13 @@ impl Session { communication: InterAgentCommunication, ) { let response_item = communication.to_model_input_item(); - let (items, _) = self.prepare_conversation_items_for_history( - turn_context, - model_info, - std::slice::from_ref(&response_item), - ); + let (items, _) = self + .prepare_conversation_items_for_history( + turn_context, + model_info, + std::slice::from_ref(&response_item), + ) + .await; let items = items.as_ref(); let response_item = items[0].clone(); { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a0c8c1701450..394ef4b06413 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -351,11 +351,13 @@ async fn default_turn_context_assigns_missing_response_item_ids() { let (session, turn_context) = make_session_and_context().await; let response_item = user_message("hello"); - let (items, _) = session.prepare_conversation_items_for_history( - &turn_context, - turn_context.model_info(), - std::slice::from_ref(&response_item), - ); + let (items, _) = session + .prepare_conversation_items_for_history( + &turn_context, + turn_context.model_info(), + std::slice::from_ref(&response_item), + ) + .await; assert!( items[0] diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index e9145c1d5d09..b30ad11aa955 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -1,6 +1,14 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_attachment_store::AttachmentStore; +use codex_attachment_store::AttachmentStoreError; +use codex_attachment_store::AttachmentStoreErrorKind; +use codex_attachment_store::ResolveFuture; +use codex_attachment_store::ResolveRequest; +use codex_attachment_store::UploadFuture; +use codex_attachment_store::UploadRequest; +use codex_attachment_store::UploadResult; use codex_core::TurnInputRequest; use codex_features::Feature; use codex_history::RolloutItem; @@ -42,8 +50,38 @@ use pretty_assertions::assert_eq; use serde_json::Value; use std::fs; use std::path::Path; +use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; +const UPLOADED_FILE_ID: &str = "file_uploaded_image"; + +#[derive(Default)] +struct RecordingFileAttachmentStore { + uploads: Mutex>, +} + +impl AttachmentStore for RecordingFileAttachmentStore { + fn upload(&self, request: UploadRequest) -> UploadFuture<'_> { + self.uploads.lock().expect("uploads lock").push(request); + Box::pin(async { + Ok(UploadResult::File { + file_id: UPLOADED_FILE_ID.to_string(), + }) + }) + } + + fn resolve<'a>(&'a self, request: ResolveRequest<'a>) -> ResolveFuture<'a> { + let file_id = request.file_id; + Box::pin(async move { + Err(AttachmentStoreError::new( + AttachmentStoreErrorKind::NotFound, + format!("attachment `{file_id}` was not found"), + )) + }) + } +} + fn find_user_message_with_image(text: &str) -> Option { for line in text.lines() { let trimmed = line.trim(); @@ -437,12 +475,15 @@ async fn resumed_history_only_emits_resize_notices_for_new_images() -> anyhow::R .join("\n"); fs::write(&rollout_path, format!("{rollout}\n"))?; - let mut resume_builder = test_codex().with_config(|config| { - let _ = config.features.enable(Feature::ImageResizeNotice); - let _ = config - .features - .enable(Feature::RetainClientDeveloperMessages); - }); + let resume_image_store = Arc::new(RecordingFileAttachmentStore::default()); + let mut resume_builder = test_codex() + .with_image_store(resume_image_store.clone()) + .with_config(|config| { + let _ = config.features.enable(Feature::ImageResizeNotice); + let _ = config + .features + .enable(Feature::RetainClientDeveloperMessages); + }); let resumed = resume_builder .resume(&server, initial.home.clone(), rollout_path.clone()) .await?; @@ -468,6 +509,16 @@ async fn resumed_history_only_emits_resize_notices_for_new_images() -> anyhow::R matches!(event, EventMsg::TurnComplete(_)) }) .await; + { + let uploads = resume_image_store.uploads.lock().expect("uploads lock"); + let [upload] = uploads.as_slice() else { + panic!("only the new image should be uploaded"); + }; + assert_eq!( + image::load_from_memory(&upload.data)?.dimensions(), + (2048, 768) + ); + } let request = resumed_mock.single_request(); assert!(request.has_content_kinds(&["images.resize_notice"])); @@ -507,6 +558,15 @@ async fn resumed_history_only_emits_resize_notices_for_new_images() -> anyhow::R .context("historical image data URL")?; let historical_image = image::load_from_memory(&BASE64_STANDARD.decode(encoded_image)?)?; assert_eq!(historical_image.dimensions(), (2048, 768)); + assert_eq!( + input[image_message_indices[1]] + .get("content") + .and_then(Value::as_array) + .and_then(|content| content.first()) + .and_then(|item| item.get("file_id")) + .and_then(Value::as_str), + Some(UPLOADED_FILE_ID) + ); let expected_notice = concat!( "\n", @@ -543,8 +603,17 @@ async fn resumed_history_only_emits_resize_notices_for_new_images() -> anyhow::R ) .await; replayed.submit_turn("preserve recorded notices").await?; - let replayed_notices = replayed_mock - .single_request() + let replayed_request = replayed_mock.single_request(); + assert!(replayed_request.input().iter().any(|item| { + item.get("content") + .and_then(Value::as_array) + .is_some_and(|content| { + content.iter().any(|item| { + item.get("file_id").and_then(Value::as_str) == Some(UPLOADED_FILE_ID) + }) + }) + })); + let replayed_notices = replayed_request .message_input_texts("developer") .into_iter() .filter(|text| text.starts_with("")) From 40584fad87aa2cd63e03db4784ccfd5b50a59bed Mon Sep 17 00:00:00 2001 From: Rennie Date: Thu, 17 Sep 2026 01:08:35 +0000 Subject: [PATCH 005/373] Keep MCP user interaction on the root thread (#46066) ## Why MCP requests that need human input, including browser sign-in, must be handled by the root thread. Subagents need guidance to hand these blockers to their parent without prompting the user or automatically accepting requests that require input. ## What changed - Reject interactive MCP elicitations and tool approval prompts in subagents with guidance to ask the parent and wait before retrying. - Recognize browser authentication and `codex_requires_user_input` metadata even when the form schema is empty, while preserving automatic permission approvals and review decisions. - Carry user-interaction eligibility through MCP runtime creation and connection reuse, and guard prompt registration. - Preserve connector authentication diagnostics alongside handoff guidance, subject to normal tool-output limits. ## Testing Add unit and integration coverage for blocked subagent prompts, root browser authentication, automatic approval and review, connection reuse, and authentication diagnostic preservation and truncation. Add a request-history snapshot for browser-auth handoff guidance. GitOrigin-RevId: 83e146b6e5c1a2a22a34681ad45615f504b3c96c --- .../apps_processor/installed.rs | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 3 + .../codex-mcp/src/connection_manager_tests.rs | 139 +++++++-- codex-rs/codex-mcp/src/elicitation.rs | 159 +++++++--- codex-rs/codex-mcp/src/elicitation_tests.rs | 268 +++++++++++++++- codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/mcp/mod.rs | 2 + codex-rs/codex-mcp/src/runtime.rs | 4 +- .../src/user_verification_elicitation.rs | 41 +-- codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/mcp_tool_call.rs | 43 ++- codex-rs/core/src/mcp_tool_call_tests.rs | 86 +++++ codex-rs/core/src/session/mcp.rs | 14 +- codex-rs/core/src/session/mcp_runtime.rs | 1 + codex-rs/core/src/session/tests.rs | 56 +++- .../tools/handlers/request_plugin_install.rs | 3 +- .../core/tests/suite/mcp_auth_elicitation.rs | 295 ++++++++++++++++-- codex-rs/core/tests/suite/mcp_auth_refresh.rs | 1 + .../tests/suite/mcp_subagent_elicitation.rs | 288 +++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/scenarios.rs | 24 ++ ...narios__subagent_browser_auth_handoff.snap | 40 +++ codex-rs/ext/mcp/src/event_stream_tests.rs | 1 + codex-rs/protocol/src/mcp_approval_meta.rs | 3 + 24 files changed, 1326 insertions(+), 149 deletions(-) create mode 100644 codex-rs/core/tests/suite/mcp_subagent_elicitation.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__subagent_browser_auth_handoff.snap diff --git a/codex-rs/app-server/src/request_processors/apps_processor/installed.rs b/codex-rs/app-server/src/request_processors/apps_processor/installed.rs index 00f4f86c49cc..1f3dc7bafa6a 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor/installed.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor/installed.rs @@ -113,6 +113,7 @@ impl AppsRequestProcessor { client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.clone(), auth_manager: codex_apps_auth_manager, + allow_user_interaction: true, elicitation_reviewer: None, elicitation_lifecycle: None, }) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index dda2edd914a1..1536a0265ad8 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -222,6 +222,7 @@ impl McpConnectionSet { client_mcp_extensions, auth, auth_manager, + allow_user_interaction, elicitation_reviewer, elicitation_lifecycle, } = input; @@ -258,6 +259,7 @@ impl McpConnectionSet { !previous.servers.is_empty() && previous.elicitation_requests.update( Arc::clone(&config), + allow_user_interaction, elicitation_reviewer.clone(), elicitation_lifecycle.clone(), ) @@ -267,6 +269,7 @@ impl McpConnectionSet { } else { ElicitationRequestManager::new( Arc::clone(&config), + allow_user_interaction, elicitation_reviewer, elicitation_lifecycle, elicitation_router, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index aadc5d688ccf..efaaa4ff4a46 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -117,6 +117,7 @@ impl McpConnectionSet { approval_policy.value(), permission_profile.get().clone(), ), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, ElicitationRequestRouter::default(), @@ -912,6 +913,7 @@ fn elicitation_granular_policy_respects_never_and_config() { async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { let manager = ElicitationRequestManager::new( test_elicitation_config("server", AskForApproval::Never, PermissionProfile::Disabled), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, ElicitationRequestRouter::default(), @@ -950,6 +952,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fields() { let manager = ElicitationRequestManager::new( test_elicitation_config("server", AskForApproval::Never, PermissionProfile::Disabled), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, ElicitationRequestRouter::default(), @@ -1045,6 +1048,7 @@ async fn assert_elicitation_declined_with_reviewer_calls( let reviewer = Arc::new(DecliningElicitationReviewer::default()); let manager = ElicitationRequestManager::new( test_elicitation_config(server_name, approval_policy, PermissionProfile::Disabled), + /*allow_user_interaction*/ true, Some(reviewer.clone()), /*lifecycle*/ None, full_access_form_input_enabled_router(), @@ -1089,6 +1093,7 @@ async fn assert_requested_user_input_is_declined( ) { let manager = ElicitationRequestManager::new( test_elicitation_config("server", approval_policy, permission_profile), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, router, @@ -1180,6 +1185,7 @@ async fn assert_disabled_permissions_surface_requested_user_input( let reviewer = Arc::new(DecliningElicitationReviewer::default()); let manager = ElicitationRequestManager::new( test_elicitation_config("server", AskForApproval::Never, PermissionProfile::Disabled), + /*allow_user_interaction*/ true, Some(reviewer.clone()), /*lifecycle*/ None, router.clone(), @@ -1330,6 +1336,7 @@ async fn on_request_approval_forms_remain_with_the_reviewer() { async fn disabled_permissions_decline_user_input_without_an_event_channel() { let manager = ElicitationRequestManager::new( test_elicitation_config("server", AskForApproval::Never, PermissionProfile::Disabled), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, full_access_form_input_enabled_router(), @@ -1369,6 +1376,7 @@ async fn concurrent_authority_updates_never_auto_approve_mixed_policy() { AskForApproval::Never, PermissionProfile::default(), ), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, ElicitationRequestRouter::default(), @@ -1382,6 +1390,7 @@ async fn concurrent_authority_updates_never_auto_approve_mixed_policy() { AskForApproval::OnRequest, PermissionProfile::Disabled ), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, )); @@ -1391,6 +1400,7 @@ async fn concurrent_authority_updates_never_auto_approve_mixed_policy() { AskForApproval::Never, PermissionProfile::default() ), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, )); @@ -1452,6 +1462,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() { AskForApproval::OnRequest, PermissionProfile::default(), ), + /*allow_user_interaction*/ true, /*reviewer*/ None, Some(lifecycle.clone()), router.clone(), @@ -1462,6 +1473,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() { AskForApproval::OnRequest, PermissionProfile::default(), ), + /*allow_user_interaction*/ true, /*reviewer*/ None, Some(lifecycle), router.clone(), @@ -1566,6 +1578,7 @@ async fn cancelled_elicitation_is_removed_without_affecting_other_pending_reques AskForApproval::OnRequest, PermissionProfile::default(), ), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, router.clone(), @@ -2045,6 +2058,7 @@ async fn read_only_apps_discovery_never_uses_a_shared_writable_catalog() -> anyh client_mcp_extensions: ClientMcpExtensions::default(), auth: None, auth_manager: None, + allow_user_interaction: true, elicitation_reviewer: None, elicitation_lifecycle: None, }, @@ -2100,6 +2114,7 @@ async fn hosted_apps_protocol_mode_is_independent_of_generic_mode() -> anyhow::R /*previous*/ None, McpPublicationGate::already_published(), McpRuntimeInput { + allow_user_interaction: true, startup_policy: McpStartupPolicy::Eager, config: Arc::new(config), plugins_available: false, @@ -2206,6 +2221,7 @@ async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow: /*previous*/ None, McpPublicationGate::already_published(), McpRuntimeInput { + allow_user_interaction: true, startup_policy: McpStartupPolicy::Eager, config: Arc::new(config), plugins_available: false, @@ -4603,6 +4619,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() - /*previous*/ None, McpPublicationGate::already_published(), McpRuntimeInput { + allow_user_interaction: true, startup_policy: McpStartupPolicy::Eager, config: Arc::new(runtime_config.clone()), plugins_available: false, @@ -4717,6 +4734,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*previous*/ None, McpPublicationGate::already_published(), McpRuntimeInput { + allow_user_interaction: true, startup_policy: McpStartupPolicy::Eager, config: Arc::new(crate::mcp::tests::test_mcp_config( codex_home.path().to_path_buf(), @@ -5140,6 +5158,7 @@ async fn reconcile_reusable_server( config, runtime_context, crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf()), + /*allow_user_interaction*/ true, ) .await } @@ -5150,12 +5169,14 @@ async fn reconcile_reusable_server_with_mcp_config( config: McpServerConfig, runtime_context: McpRuntimeContext, mcp_config: crate::McpConfig, + allow_user_interaction: bool, ) -> McpConnectionSet { let (tx_event, _rx_event) = async_channel::unbounded(); McpConnectionSet::new( Some(previous), McpPublicationGate::already_published(), McpRuntimeInput { + allow_user_interaction, startup_policy: McpStartupPolicy::Eager, config: Arc::new(mcp_config), plugins_available: false, @@ -5204,6 +5225,7 @@ async fn read_only_policy_does_not_reuse_a_writable_connection() { config.clone(), runtime_context.clone(), mcp_config, + /*allow_user_interaction*/ true, ) .await; assert_eq!( @@ -5433,6 +5455,7 @@ async fn refreshed_catalog_follows_reused_client_without_mutating_old_bindings() config, runtime_context, crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf()), + /*allow_user_interaction*/ true, ) .await, ); @@ -5907,6 +5930,7 @@ async fn reconciliation_replaces_connection_when_protocol_mode_changes() { Some(&previous), McpPublicationGate::already_published(), McpRuntimeInput { + allow_user_interaction: true, startup_policy: McpStartupPolicy::Eager, config: Arc::new(mcp_config), plugins_available: false, @@ -5965,6 +5989,7 @@ async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabl Some(&previous), McpPublicationGate::already_published(), McpRuntimeInput { + allow_user_interaction: true, startup_policy: McpStartupPolicy::Eager, config: Arc::new(mcp_config), plugins_available: false, @@ -5999,42 +6024,96 @@ async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabl async fn reconciliation_updates_elicitation_policy_without_restarting_ready_server() { let runtime_context = reusable_server_runtime_context(); let config = reusable_server_config("http://127.0.0.1:1"); - let previous = manager_with_reusable_ready_server( + let mut previous = manager_with_reusable_ready_server( &config, &runtime_context, vec![create_test_tool("docs", "search")], ) .await; - { - let mut authority = previous - .elicitation_requests - .authority - .lock() - .expect("elicitation authority lock"); - let config = Arc::make_mut( - &mut authority - .as_mut() - .expect("test manager should have permission authority") - .config, - ); - config.approval_policy = Constrained::allow_any(AskForApproval::Never); - config.permission_profile = PermissionProfile::Disabled; - } + let router = ElicitationRequestRouter::default(); + previous.elicitation_requests = ElicitationRequestManager::new( + test_elicitation_config("docs", AskForApproval::Never, PermissionProfile::Disabled), + /*allow_user_interaction*/ true, + /*reviewer*/ None, + /*lifecycle*/ None, + router.clone(), + ); + let (tx_event, events) = async_channel::unbounded(); + let sender = previous.elicitation_requests.make_sender( + "docs".to_string(), + Some(tx_event), + &ClientMcpExtensions::default(), + ); + let elicitation = + codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "What should I say?".to_string(), + requested_schema: requested_user_input_schema(), + }); + let response = ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(serde_json::json!({"message": "continue"})), + meta: None, + }; - let reconciled = reconcile_reusable_server(&previous, config, runtime_context).await; + for allow_user_interaction in [true, false, true] { + let mcp_config = test_elicitation_config( + "docs", + AskForApproval::OnRequest, + PermissionProfile::default(), + ); + let reconciled = reconcile_reusable_server_with_mcp_config( + &previous, + "docs", + config.clone(), + runtime_context.clone(), + mcp_config.as_ref().clone(), + allow_user_interaction, + ) + .await; + assert!(previous.shares_test_connection_with(&reconciled, "docs")); + { + let authority = reconciled + .elicitation_requests + .authority + .lock() + .expect("elicitation authority lock"); + let config = &authority.as_ref().expect("elicitation authority").config; + assert_eq!(config.approval_policy.value(), AskForApproval::OnRequest); + assert_eq!(config.permission_profile, PermissionProfile::default()); + } - assert!(previous.shares_test_connection_with(&reconciled, "docs")); - let authority = reconciled - .elicitation_requests - .authority - .lock() - .expect("elicitation authority lock"); - let config = &authority - .as_ref() - .expect("reconciled manager should have permission authority") - .config; - assert_eq!(config.approval_policy.value(), AskForApproval::OnRequest); - assert_eq!(config.permission_profile, PermissionProfile::default()); + // A sender captured before reconciliation must observe each ownership update. + let mut pending = sender(NumberOrString::Number(7), elicitation.clone()); + if allow_user_interaction { + assert!(futures::poll!(pending.as_mut()).is_pending()); + let EventMsg::ElicitationRequest(request) = + events.try_recv().expect("user-input event").msg + else { + panic!("expected MCP elicitation"); + }; + let codex_protocol::mcp::RequestId::String(request_id) = request.id else { + panic!("expected Codex-owned string request ID"); + }; + router + .resolve( + "docs".to_string(), + NumberOrString::String(request_id.into()), + response.clone(), + ) + .await + .expect("user response should resolve the retained sender"); + assert_eq!(pending.await.expect("elicitation should resolve"), response); + } else { + let error = pending + .now_or_never() + .expect("blocked elicitation must not wait for user input") + .expect_err("subagent user input must be rejected"); + assert_eq!(error.to_string(), crate::MCP_ELICITATION_HANDOFF_MESSAGE); + } + assert!(events.is_empty()); + previous = reconciled; + } } #[tokio::test] @@ -6220,6 +6299,7 @@ async fn reconciliation_reconnects_when_host_plugin_root_changes() { server_config.clone(), runtime_context.clone(), config_for_root(original_root), + /*allow_user_interaction*/ true, ) .await; assert!(previous.shares_test_connection_with(&unchanged, "docs")); @@ -6231,6 +6311,7 @@ async fn reconciliation_reconnects_when_host_plugin_root_changes() { server_config, runtime_context, replacement_config, + /*allow_user_interaction*/ true, ) .await; assert!(!unchanged.shares_test_connection_with(&replacement, "docs")); diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index ec3d3f0d0fbf..65eeaa724cbb 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -4,6 +4,8 @@ //! from the user. It decides whether the request can be automatically accepted, //! must be declined by policy, or should be surfaced as a Codex protocol event //! and later resolved through the stored responder. +//! Non-root agents may use automatic decisions, but explicit user-input requests +//! are rejected before review and no pending user prompt is registered. use std::collections::HashMap; use std::sync::Arc; @@ -24,9 +26,11 @@ use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID; use codex_protocol::mcp::RequestId as ProtocolRequestId; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_BROWSER_AUTH; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION; use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY; +use codex_protocol::mcp_approval_meta::REQUIRES_USER_INPUT_KEY; use codex_protocol::mcp_approval_meta::STRICT_AUTO_REVIEW_KEY; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; @@ -44,6 +48,12 @@ use tokio::sync::oneshot; static NEXT_ELICITATION_REQUEST_ID: AtomicU64 = AtomicU64::new(0); const STRICT_AUTO_REVIEW_DECLINE_MESSAGE: &str = "Automated review of this operation failed. Do not proceed without asking the user for explicit approval."; +/// Recovery guidance for a request that a non-root agent cannot present to the user. +pub const MCP_ELICITATION_HANDOFF_MESSAGE: &str = concat!( + "MCP server elicitations can only be requested by the root thread. ", + "Ask the parent agent to handle this request. ", + "Do not retry the blocked action until the parent confirms the blocker is resolved." +); #[path = "user_verification_elicitation.rs"] mod user_verification_elicitation; @@ -154,11 +164,78 @@ impl ElicitationRequestRouter { .send(response) .map_err(|_| anyhow!("elicitation response receiver closed")) } + + async fn request_user_interaction( + &self, + events: Option>, + authority: &ElicitationAuthority, + server_name: String, + request: ElicitationRequest, + ) -> Result { + anyhow::ensure!( + authority.allow_user_interaction, + MCP_ELICITATION_HANDOFF_MESSAGE + ); + let Some(events) = events else { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + }; + let (delivery_context, response_context) = + if matches!(&request, ElicitationRequest::UserVerification { .. }) { + ( + "failed to deliver user-verification request", + "user-verification response channel closed", + ) + } else { + ( + "failed to deliver MCP elicitation request", + "elicitation request channel closed unexpectedly", + ) + }; + let public_request_id = format!( + "codex-mcp-elicitation-{}", + NEXT_ELICITATION_REQUEST_ID.fetch_add(1, Ordering::Relaxed) + ); + let request_key = ( + server_name.clone(), + RequestId::String(public_request_id.clone().into()), + ); + let (tx, rx) = oneshot::channel(); + let _active_elicitation = authority + .lifecycle + .as_ref() + .map(ElicitationLifecycle::start); + self.requests + .lock() + .map_err(|_| anyhow!("elicitation request router unavailable"))? + .insert(request_key.clone(), tx); + let _pending_request = PendingElicitationRequest { + router: self.clone(), + key: request_key, + }; + events + .send(Event { + id: "mcp_elicitation_request".to_string(), + msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: None, + server_name, + id: ProtocolRequestId::String(public_request_id), + request, + }), + }) + .await + .context(delivery_context)?; + rx.await.context(response_context) + } } #[derive(Clone)] pub(crate) struct ElicitationAuthority { pub(crate) config: Arc, + pub(crate) allow_user_interaction: bool, reviewer: Option, lifecycle: Option, } @@ -172,6 +249,7 @@ pub(crate) struct ElicitationRequestManager { impl ElicitationRequestManager { pub(crate) fn new( config: Arc, + allow_user_interaction: bool, reviewer: Option, lifecycle: Option, router: ElicitationRequestRouter, @@ -180,6 +258,7 @@ impl ElicitationRequestManager { router, authority: Arc::new(StdMutex::new(Some(ElicitationAuthority { config, + allow_user_interaction, reviewer, lifecycle, }))), @@ -189,6 +268,7 @@ impl ElicitationRequestManager { pub(crate) fn update( &self, config: Arc, + allow_user_interaction: bool, reviewer: Option, lifecycle: Option, ) -> bool { @@ -197,6 +277,7 @@ impl ElicitationRequestManager { }; *authority = Some(ElicitationAuthority { config, + allow_user_interaction, reviewer, lifecycle, }); @@ -223,6 +304,40 @@ impl ElicitationRequestManager { let server_name = server_name.clone(); let authority = authority.clone(); async move { + let authority = authority + .lock() + .ok() + .and_then(|authority| authority.clone()); + let user_interaction_disabled = authority + .as_ref() + .is_some_and(|authority| !authority.allow_user_interaction); + if user_interaction_disabled { + // Browser sign-in can use an empty schema. Explicit user-input + // markers must take precedence over automatic approval or review. + let requires_user_input = elicitation.meta().is_some_and(|meta| { + meta.get(REQUIRES_USER_INPUT_KEY) == Some(&Value::Bool(true)) + || meta.get(APPROVAL_KIND_KEY).and_then(Value::as_str) + == Some(APPROVAL_KIND_BROWSER_AUTH) + }) || match &elicitation { + Elicitation::Mcp( + rmcp::model::ElicitRequestParams::FormElicitationParams { + requested_schema, + .. + }, + ) => !requested_schema.properties.is_empty(), + Elicitation::OpenAiForm { + requested_schema, .. + } + | Elicitation::OpenAiElicitationForm { + requested_schema, .. + } => requested_schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| !properties.is_empty()), + Elicitation::Mcp(_) | Elicitation::UserVerification { .. } => true, + }; + anyhow::ensure!(!requires_user_input, MCP_ELICITATION_HANDOFF_MESSAGE); + } if let Elicitation::UserVerification { title, description, @@ -270,8 +385,7 @@ impl ElicitationRequestManager { }); } - let Ok(Some(authority)) = authority.lock().map(|authority| authority.clone()) - else { + let Some(authority) = authority else { return Ok(ElicitationResponse { action: ElicitationAction::Decline, content: None, @@ -282,7 +396,8 @@ impl ElicitationRequestManager { config, reviewer, lifecycle, - } = authority; + .. + } = &authority; let approval_policy = config.approval_policy.value(); let Some(permission_profile) = config.permission_profile_for_server(&server_name) else { @@ -400,19 +515,6 @@ impl ElicitationRequestManager { } } - let Some(tx_event) = tx_event else { - return Ok(ElicitationResponse { - action: ElicitationAction::Decline, - content: None, - meta: None, - }); - }; - - let public_request_id = format!( - "codex-mcp-elicitation-{}", - NEXT_ELICITATION_REQUEST_ID.fetch_add(1, Ordering::Relaxed) - ); - let routed_request_id = RequestId::String(public_request_id.clone().into()); let request = match elicitation { Elicitation::UserVerification { .. } => { return Ok(ElicitationResponse { @@ -474,32 +576,9 @@ impl ElicitationRequestManager { requested_schema, }, }; - let (tx, rx) = oneshot::channel(); - let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start); - let request_key = (server_name.clone(), routed_request_id); router - .requests - .lock() - .map_err(|_| anyhow!("elicitation request router unavailable"))? - .insert(request_key.clone(), tx); - let _pending_request = PendingElicitationRequest { - router: router.clone(), - key: request_key, - }; - tx_event - .send(Event { - id: "mcp_elicitation_request".to_string(), - msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { - turn_id: None, - server_name, - id: ProtocolRequestId::String(public_request_id), - request, - }), - }) + .request_user_interaction(tx_event, &authority, server_name, request) .await - .context("failed to deliver MCP elicitation request")?; - rx.await - .context("elicitation request channel closed unexpectedly") } .boxed() }) diff --git a/codex-rs/codex-mcp/src/elicitation_tests.rs b/codex-rs/codex-mcp/src/elicitation_tests.rs index 3e9f0867f498..4f2472bff91e 100644 --- a/codex-rs/codex-mcp/src/elicitation_tests.rs +++ b/codex-rs/codex-mcp/src/elicitation_tests.rs @@ -38,11 +38,17 @@ impl ElicitationReviewer for RecordingReviewer { assert_eq!(request.server_name, "independent-mcp"); self.calls.fetch_add(/*val*/ 1, Relaxed); let active_elicitations = self.active_elicitations.clone(); + let active_during_review = usize::from( + request + .elicitation + .meta() + .is_some_and(|meta| meta.get(STRICT_AUTO_REVIEW_KEY) == Some(&Value::Bool(true))), + ); let response = self.response.clone(); async move { - assert_eq!(active_elicitations.load(Relaxed), 1); + assert_eq!(active_elicitations.load(Relaxed), active_during_review); tokio::task::yield_now().await; - assert_eq!(active_elicitations.load(Relaxed), 1); + assert_eq!(active_elicitations.load(Relaxed), active_during_review); response.map_err(anyhow::Error::msg) } .boxed() @@ -87,6 +93,7 @@ fn elicitation_fixture( .insert("another-independent-mcp".to_string(), permission_profile); let manager = ElicitationRequestManager::new( config, + /*allow_user_interaction*/ true, reviewer.map(|reviewer| reviewer as Arc), lifecycle, ElicitationRequestRouter::default(), @@ -113,6 +120,243 @@ async fn send_elicitation(sender: &SendElicitation, marker: Option) -> El .expect("elicitation must receive a terminal response") } +fn disable_user_interaction(manager: &ElicitationRequestManager) { + let authority = manager.authority.lock().unwrap().clone().unwrap(); + assert!(manager.update( + authority.config, + /*allow_user_interaction*/ false, + authority.reviewer, + authority.lifecycle, + )); +} + +fn form_elicitations(meta: Option, properties: Value) -> [Elicitation; 3] { + let schema = json!({"type": "object", "properties": properties}); + [ + Elicitation::Mcp(ElicitRequestParams::FormElicitationParams { + meta: meta + .as_ref() + .map(|meta| RequestMetaObject::from(meta.as_object().unwrap().clone())), + message: "Review this request".into(), + requested_schema: serde_json::from_value(schema.clone()).unwrap(), + }), + Elicitation::OpenAiForm { + meta: meta.clone(), + message: "Review this request".into(), + requested_schema: schema.clone(), + }, + Elicitation::OpenAiElicitationForm { + meta, + message: "Review this request".into(), + requested_schema: schema, + }, + ] +} + +async fn assert_subagent_elicitation_blocked(sender: &SendElicitation, elicitation: Elicitation) { + let error = tokio::time::timeout( + std::time::Duration::from_secs(1), + sender(RequestId::Number(7), elicitation), + ) + .await + .expect("blocked elicitation must not wait for user input") + .unwrap_err(); + assert_eq!(error.to_string(), MCP_ELICITATION_HANDOFF_MESSAGE); +} + +#[tokio::test] +async fn subagent_human_input_is_rejected_before_automatic_approval() { + let mut requests = Vec::new(); + for meta in [ + json!({"codex_approval_kind": "browser_auth"}), + json!({"codex_approval_kind": "browser_auth", "codex_requires_user_input": true}), + json!({"codex_requires_user_input": true}), + json!({ + "codex_approval_kind": "mcp_tool_call", + "codex_request_type": "approval_request", + "codex_strict_auto_review": true, + "codex_requires_user_input": true, + }), + ] { + requests.extend(form_elicitations(Some(meta), json!({}))); + } + for meta in [None, Some(json!({"codex_strict_auto_review": true}))] { + requests.extend(form_elicitations( + meta.clone(), + json!({"answer": {"type": "string"}}), + )); + requests.push(Elicitation::Mcp( + ElicitRequestParams::UrlElicitationParams { + meta: meta.map(|meta| RequestMetaObject::from(meta.as_object().unwrap().clone())), + message: "Sign in to continue".into(), + url: "https://example.com/auth".into(), + elicitation_id: "test-auth".into(), + }, + )); + } + + for approval_policy in [AskForApproval::OnRequest, AskForApproval::Never] { + let reviewer = RecordingReviewer::new(Ok(Some(approved_response()))); + let (manager, events, sender) = elicitation_fixture( + approval_policy, + PermissionProfile::Disabled, + Some(reviewer.clone()), + ); + // Update after creating the sender so a retained connection observes the restriction. + disable_user_interaction(&manager); + for elicitation in requests.iter().cloned() { + assert_subagent_elicitation_blocked(&sender, elicitation).await; + } + assert!(events.is_empty()); + assert!(manager.router.requests.lock().unwrap().is_empty()); + assert_eq!(reviewer.calls.load(Relaxed), 0); + assert_eq!(reviewer.active_elicitations.load(Relaxed), 0); + + let (manager, events, sender) = + verification_fixture(approval_policy, Some(reviewer.clone())); + disable_user_interaction(&manager); + assert_subagent_elicitation_blocked( + &sender, + Elicitation::UserVerification { + title: "Approve purchase".into(), + description: "Pay $200".into(), + challenge: "AQID".into(), + }, + ) + .await; + assert!(events.is_empty()); + assert!(manager.router.requests.lock().unwrap().is_empty()); + assert_eq!(reviewer.calls.load(Relaxed), 0); + } +} + +#[tokio::test] +async fn subagent_permission_preserves_automatic_review_decisions() { + for (strict_auto_review, approval_policy, expected_reviews) in [ + (false, AskForApproval::OnRequest, 1), + (true, AskForApproval::OnRequest, 1), + (true, AskForApproval::Never, 1), + (false, AskForApproval::Never, 0), + ] { + for response in [ + approved_response(), + ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: Some(json!({"approvals_reviewer": "auto_review", "message": "Denied"})), + }, + ] { + let reviewer = RecordingReviewer::new(Ok(Some(response.clone()))); + let (manager, events, sender) = elicitation_fixture( + approval_policy, + PermissionProfile::Disabled, + Some(reviewer.clone()), + ); + disable_user_interaction(&manager); + let [request, _, _] = form_elicitations( + Some(json!({ + "codex_request_type": "approval_request", + "codex_approval_kind": "mcp_tool_call", + "codex_strict_auto_review": strict_auto_review, + "tool_name": "access_browser_origin", + "tool_params": {"origin": "https://example.com"}, + })), + json!({}), + ); + let expected = if expected_reviews == 0 { + ElicitationResponse { + meta: None, + ..approved_response() + } + } else { + response + }; + assert_eq!( + sender(RequestId::Number(7), request).await.unwrap(), + expected + ); + assert_eq!(reviewer.calls.load(Relaxed), expected_reviews); + assert_eq!(reviewer.active_elicitations.load(Relaxed), 0); + assert!(events.is_empty()); + assert!(manager.router.requests.lock().unwrap().is_empty()); + } + } +} + +#[tokio::test] +async fn subagent_without_an_automatic_decision_does_not_prompt() { + for reviewer in [None, Some(RecordingReviewer::new(Ok(None)))] { + let (manager, events, sender) = elicitation_fixture( + AskForApproval::OnRequest, + PermissionProfile::read_only(), + reviewer.clone(), + ); + disable_user_interaction(&manager); + for elicitation in form_elicitations(/*meta*/ None, json!({})) { + assert_subagent_elicitation_blocked(&sender, elicitation).await; + } + assert_eq!( + send_elicitation(&sender, Some(json!(true))).await, + strict_auto_review_decline() + ); + if let Some(reviewer) = reviewer { + assert_eq!(reviewer.calls.load(Relaxed), 4); + assert_eq!(reviewer.active_elicitations.load(Relaxed), 0); + } + assert!(events.is_empty()); + assert!(manager.router.requests.lock().unwrap().is_empty()); + } +} + +#[test] +fn final_prompt_guard_rejects_subagents_before_registration() { + let (manager, _, _) = verification_fixture(AskForApproval::OnRequest, /*reviewer*/ None); + let mut authority = manager.authority.lock().unwrap().clone().unwrap(); + authority.allow_user_interaction = false; + let registrations = Arc::new(AtomicUsize::new(0)); + let starts = registrations.clone(); + authority.lifecycle = Some(ElicitationLifecycle::new(move || { + starts.fetch_add(/*val*/ 1, Relaxed); + })); + let (tx, events) = async_channel::bounded(1); + for sender in [Some(tx.clone()), None] { + let error = manager + .router + .request_user_interaction( + sender, + &authority, + crate::CODEX_APPS_MCP_SERVER_NAME.into(), + ElicitationRequest::Form { + meta: None, + message: "Confirm the action".into(), + requested_schema: json!({"type": "object", "properties": {}}), + }, + ) + .now_or_never() + .expect("a subagent must not wait for user input") + .unwrap_err(); + assert_eq!(error.to_string(), MCP_ELICITATION_HANDOFF_MESSAGE); + } + let error = user_verification_elicitation::route( + manager.router.clone(), + Some(tx), + Some(authority), + crate::CODEX_APPS_MCP_SERVER_NAME.into(), + ElicitationRequest::UserVerification { + title: "Approve purchase".into(), + description: "Pay $200".into(), + challenge: "AQID".into(), + }, + ) + .now_or_never() + .expect("direct verification routing must not wait for a subagent") + .unwrap_err(); + assert_eq!(error.to_string(), MCP_ELICITATION_HANDOFF_MESSAGE); + assert_eq!(registrations.load(Relaxed), 0); + assert!(events.is_empty()); + assert!(manager.router.requests.lock().unwrap().is_empty()); +} + async fn assert_declined(marker: Value, response: Option) { let expected_calls = usize::from(marker == Value::Bool(true)); let reviewer = response.map(RecordingReviewer::new); @@ -150,6 +394,7 @@ fn closed_event_channel_immediately_cleans_up_pending_elicitation() { AskForApproval::OnRequest, PermissionProfile::Disabled ), + /*allow_user_interaction*/ true, /*reviewer*/ None, Some(lifecycle), )); @@ -338,6 +583,7 @@ async fn reused_elicitation_senders_follow_each_servers_latest_permission_author let manager = ElicitationRequestManager::new( Arc::new(config.clone()), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, ElicitationRequestRouter::default(), @@ -368,6 +614,7 @@ async fn reused_elicitation_senders_follow_each_servers_latest_permission_author ); assert!(manager.update( Arc::new(config.clone()), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, )); @@ -391,6 +638,7 @@ async fn reused_elicitation_senders_follow_each_servers_latest_permission_author ); assert!(manager.update( Arc::new(config.clone()), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, )); @@ -403,6 +651,7 @@ async fn reused_elicitation_senders_follow_each_servers_latest_permission_author config.set_server_permission_profiles(&servers, std::iter::empty()); assert!(manager.update( Arc::new(config.clone()), + /*allow_user_interaction*/ true, /*reviewer*/ None, /*lifecycle*/ None, )); @@ -434,6 +683,7 @@ fn verification_fixture( Arc::make_mut(&mut config).mcp_server_catalog = catalog.build(); let manager = ElicitationRequestManager::new( config, + /*allow_user_interaction*/ true, reviewer.map(|reviewer| reviewer as Arc), /*lifecycle*/ None, ElicitationRequestRouter::default(), @@ -574,6 +824,18 @@ async fn user_verification_cancels_for_an_event_receiver_without_host_activation async fn user_verification_drops_pending_response_when_the_request_is_cancelled() { let (manager, events, sender) = verification_fixture(AskForApproval::OnRequest, /*reviewer*/ None); + let active_elicitations = Arc::new(AtomicUsize::new(0)); + let registrations = active_elicitations.clone(); + manager + .authority + .lock() + .unwrap() + .as_mut() + .unwrap() + .lifecycle = Some(ElicitationLifecycle::new(move || { + registrations.fetch_add(/*val*/ 1, Relaxed); + LifecycleRegistration(registrations.clone()) + })); let pending = tokio::spawn(sender( RequestId::Number(7), Elicitation::UserVerification { @@ -583,9 +845,11 @@ async fn user_verification_drops_pending_response_when_the_request_is_cancelled( }, )); events.recv().await.unwrap(); + assert_eq!(active_elicitations.load(Relaxed), 1); pending.abort(); assert!(pending.await.unwrap_err().is_cancelled()); assert!(manager.router.requests.lock().unwrap().is_empty()); + assert_eq!(active_elicitations.load(Relaxed), 0); } #[tokio::test] diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 77dc37f1a60e..9acb97e52d0a 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -8,6 +8,7 @@ pub use elicitation::ElicitationLifecycle; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; pub use elicitation::ElicitationReviewerHandle; +pub use elicitation::MCP_ELICITATION_HANDOFF_MESSAGE; pub use event_stream::McpEventStreamOpener; pub use resource_client::CodexAppsResourceListParams; pub use resource_client::McpEventCatalogSnapshot; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index a65da6e6977c..0f902be3fbb9 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -458,6 +458,7 @@ pub async fn read_mcp_resource( client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.cloned(), auth_manager: None, + allow_user_interaction: true, elicitation_reviewer: None, elicitation_lifecycle: None, }, @@ -537,6 +538,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.cloned(), auth_manager: None, + allow_user_interaction: true, elicitation_reviewer: None, elicitation_lifecycle: None, }, diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 804ae9e38a3f..17aa9f557b74 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -71,7 +71,7 @@ pub enum McpStartupPolicy { LazyWhenCached, } -/// Everything needed to materialize one exact MCP configuration. +/// Configuration and owning-thread state needed to materialize an MCP runtime. pub struct McpRuntimeInput { pub startup_policy: McpStartupPolicy, pub config: Arc, @@ -88,6 +88,8 @@ pub struct McpRuntimeInput { pub client_mcp_extensions: ClientMcpExtensions, pub auth: Option, pub auth_manager: Option>, + /// Whether the owning thread may prompt the user; automatic decisions remain available. + pub allow_user_interaction: bool, pub elicitation_reviewer: Option, pub elicitation_lifecycle: Option, } diff --git a/codex-rs/codex-mcp/src/user_verification_elicitation.rs b/codex-rs/codex-mcp/src/user_verification_elicitation.rs index 5b2890400c25..9c9334aa7066 100644 --- a/codex-rs/codex-mcp/src/user_verification_elicitation.rs +++ b/codex-rs/codex-mcp/src/user_verification_elicitation.rs @@ -5,14 +5,10 @@ use super::*; pub(super) async fn route( router: ElicitationRequestRouter, events: Option>, - authority: Arc>>, + authority: Option, server_name: String, request: ElicitationRequest, ) -> Result { - let authority = authority - .lock() - .ok() - .and_then(|authority| authority.clone()); let plugin_service = authority.as_ref().is_some_and(|authority| { authority .config @@ -24,42 +20,17 @@ pub(super) async fn route( .is_host_owned_apps(&server_name, server.config()) }) }); - let Some(events) = events.filter(|_| plugin_service && !router.auto_deny()) else { + let Some((events, authority)) = events + .zip(authority) + .filter(|_| plugin_service && !router.auto_deny()) + else { return Ok(ElicitationResponse { action: ElicitationAction::Cancel, content: None, meta: None, }); }; - let id = format!( - "codex-mcp-elicitation-{}", - NEXT_ELICITATION_REQUEST_ID.fetch_add(1, Ordering::Relaxed) - ); - let key = (server_name.clone(), RequestId::String(id.clone().into())); - let (response, receiver) = oneshot::channel(); router - .requests - .lock() - .map_err(|_| anyhow!("elicitation request router unavailable"))? - .insert(key.clone(), response); - let _pending = PendingElicitationRequest { router, key }; - let _active = authority - .as_ref() - .and_then(|authority| authority.lifecycle.as_ref()) - .map(ElicitationLifecycle::start); - events - .send(Event { - id: "mcp_elicitation_request".to_string(), - msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { - turn_id: None, - server_name, - id: ProtocolRequestId::String(id), - request, - }), - }) + .request_user_interaction(Some(events), &authority, server_name, request) .await - .context("failed to deliver user-verification request")?; - receiver - .await - .context("user-verification response channel closed") } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 4156fcfc39c9..f0a4cc2c928a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -269,6 +269,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( client_mcp_extensions: ClientMcpExtensions::default(), auth: auth.clone(), auth_manager: codex_apps_auth_manager, + allow_user_interaction: true, elicitation_reviewer: None, elicitation_lifecycle: None, }) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 2e6c191852b3..5e2e369a4ec6 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -781,7 +781,7 @@ async fn maybe_request_codex_apps_auth_elicitation( url: plan.elicitation.url, elicitation_id: plan.elicitation.elicitation_id, }; - let response = sess + let outcome = match sess .request_mcp_server_elicitation( turn_context, CODEX_APPS_MCP_SERVER_NAME.to_string(), @@ -789,8 +789,33 @@ async fn maybe_request_codex_apps_auth_elicitation( request, ) .await - .response; - if !response + { + Ok(outcome) => outcome, + Err(error) => { + let mut result = result; + // Direct output prefers structured content. Preserve its diagnostic as + // text so both output paths show it alongside the recovery guidance. + if let Some(structured_content) = result.structured_content.take() { + result.content.push(serde_json::json!({ + "type": "text", + "text": structured_content.to_string(), + })); + } + result.content.insert( + /*index*/ 0, + serde_json::json!({ + "type": "text", + "text": format!( + "Authentication for {} could not be completed. {error}", + plan.auth_failure.connector_name + ), + }), + ); + return result; + } + }; + if !outcome + .response .as_ref() .is_some_and(|response| response.action == ElicitationAction::Accept) { @@ -1597,6 +1622,10 @@ pub(crate) async fn request_mcp_tool_user_approval( ); } + if turn_context.session_source.is_non_root_agent() { + return ReviewDecision::denied(codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE); + } + let tool_call_mcp_elicitation_enabled = turn_context .config .features @@ -1662,9 +1691,13 @@ pub(crate) async fn request_mcp_tool_user_approval( .map(|rendered_template| rendered_template.elicitation_message.as_str()), prompt_options, }); - let outcome = sess + let outcome = match sess .request_mcp_server_elicitation(turn_context, server.clone(), request_id, request) - .await; + .await + { + Ok(outcome) => outcome, + Err(error) => return ReviewDecision::denied(error.to_string()), + }; ( outcome.sent, parse_mcp_tool_approval_elicitation_response(outcome.response, &question_id), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index ae1d03b484aa..d9f156c631c9 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -13,6 +13,9 @@ use crate::session::tests::update_turn_settings_for_test; use crate::session::turn_context::TurnEnvironment; use crate::state::ActiveTurn; use crate::test_support::models_manager_with_provider; +use crate::tools::context::McpToolOutput; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; use crate::turn_metadata::ExecutionMetadata; use codex_app_server_protocol as app_server_protocol; @@ -30,6 +33,7 @@ use codex_hooks::HooksConfig; use codex_model_provider::create_model_provider; use codex_protocol::ResponseItemId; use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EnvironmentConfig; @@ -1724,6 +1728,88 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu assert!(rx_event.try_recv().is_err()); } +#[tokio::test] +async fn codex_apps_auth_elicitation_returns_subagent_handoff_and_diagnostics() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .session_source = SessionSource::SubAgent(SubAgentSource::Review); + let structured_content = serde_json::json!({ + "error": "reauthentication_required", "status": 401 + }); + let mut result = codex_apps_auth_failure_result(); + result.structured_content = Some(structured_content.clone()); + let diagnostic = format!( + "Connector reauthentication required: {}", + "diagnostic detail ".repeat(/*n*/ 500) + ); + result.content[0]["text"] = serde_json::json!(diagnostic); + let metadata = codex_apps_auth_failure_metadata(); + let returned = tokio::time::timeout( + std::time::Duration::from_secs(1), + maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + turn_context.approval_policy(), + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ), + ) + .await + .expect("subagent auth must not wait for user input"); + + assert_eq!(returned.meta, result.meta); + assert_eq!(returned.is_error, Some(true)); + assert!(rx_event.try_recv().is_err()); + + let output = McpToolOutput { + result: returned, + tool_input: serde_json::json!({}), + result_metadata_capture_allowed: true, + wall_time: std::time::Duration::ZERO, + original_image_detail_supported: false, + truncation_policy: TruncationPolicy::Tokens(256), + }; + let payload = ToolPayload::Function { + arguments: "{}".to_string(), + }; + let code_mode = output.code_mode_result(&payload); + assert_eq!(code_mode["isError"], serde_json::json!(true)); + assert!(code_mode.get("_meta").is_none()); + let code_text = code_mode["content"] + .as_array() + .expect("MCP content") + .iter() + .map(|item| item["text"].as_str().expect("auth diagnostic text")) + .collect::>() + .join("\n"); + assert!(code_text.contains("Authentication for Google Calendar could not be completed.")); + assert!(code_text.contains(codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE)); + assert!(code_text.contains(&diagnostic)); + assert!(code_text.contains(&structured_content.to_string())); + assert_eq!(output.tool_result_metadata(), result.meta.as_ref()); + let mut expected_hook = code_mode; + expected_hook["_meta"] = result.meta.expect("original auth metadata"); + assert_eq!( + output.post_tool_use_response("call_123", &payload), + Some(expected_hook) + ); + + let ResponseInputItem::FunctionCallOutput { + output: truncated, .. + } = output.to_response_item("call_123", &payload) + else { + panic!("expected FunctionCallOutput"); + }; + let truncated_text = truncated.body.to_text().expect("truncated auth diagnostic"); + assert!(truncated_text.contains(codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE)); + assert!(truncated_text.contains("truncated")); + assert!(!truncated_text.contains(&diagnostic)); + assert_eq!(truncated.success, Some(false)); +} + #[tokio::test] async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation() { let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 70656904abb4..11555def8a13 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -556,16 +556,20 @@ impl Session { server_name: String, request_id: RequestId, request: ElicitationRequest, - ) -> McpServerElicitationOutcome { + ) -> anyhow::Result { + anyhow::ensure!( + !turn_context.session_source.is_non_root_agent(), + codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE + ); if self.services.mcp_runtime.elicitations_auto_deny() { - return McpServerElicitationOutcome { + return Ok(McpServerElicitationOutcome { response: Some(ElicitationResponse { action: codex_rmcp_client::ElicitationAction::Accept, content: Some(serde_json::json!({})), meta: None, }), sent: false, - }; + }); } let _elicitation = self.services.elicitations.register(); @@ -617,10 +621,10 @@ impl Session { plugin_install_telemetry.tool_name.as_str(), ); } - McpServerElicitationOutcome { + Ok(McpServerElicitationOutcome { response: rx_response.await.ok(), sent: true, - } + }) } #[expect( diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index b85bbc2ffe11..7d6c5e2cfb1e 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -404,6 +404,7 @@ impl Session { client_mcp_extensions: self.services.client_mcp_extensions.for_mcp_servers(), auth, auth_manager: Some(Arc::clone(&self.services.auth_manager)), + allow_user_interaction: !desired.session_source.is_non_root_agent(), elicitation_reviewer, elicitation_lifecycle: Some(self.mcp_elicitation_lifecycle()), } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 394ef4b06413..723f85a88210 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -87,6 +87,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSandboxPolicyContext; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::EnvironmentConfigState; +use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::request_permissions::PermissionGrantScope; @@ -652,7 +653,8 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() }), }, ) - .await; + .await + .expect("root thread elicitation should be accepted"); assert_eq!( response.response, @@ -666,6 +668,58 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() assert!(rx.try_recv().is_err()); } +#[test_case(false; "interactive")] +#[test_case(true; "auto_accept")] +#[tokio::test] +async fn request_mcp_server_elicitation_rejects_non_root_threads(auto_deny: bool) { + for source in [ + SessionSource::SubAgent(SubAgentSource::Review), + SessionSource::Internal(InternalSessionSource::Guardian), + ] { + let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; + Arc::get_mut(&mut turn_context) + .expect("turn context should not be shared") + .session_source = source; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + session + .services + .mcp_runtime + .set_elicitations_auto_deny(auto_deny); + let paused = session.subscribe_elicitation_pause_state(); + + let Err(error) = tokio::time::timeout( + Duration::from_secs(1), + session.request_mcp_server_elicitation( + turn_context.as_ref(), + "codex_apps".to_string(), + RequestId::String("request-1".into()), + ElicitationRequest::Url { + meta: None, + message: "Connect this app to continue.".to_string(), + url: "https://example.com/connect".to_string(), + elicitation_id: "connect-1".to_string(), + }, + ), + ) + .await + .expect("non-root elicitation must not wait for user input") else { + panic!("non-root elicitation must be rejected"); + }; + + assert_eq!( + error.to_string(), + codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE + ); + assert!(rx.try_recv().is_err()); + assert!(!*paused.borrow()); + assert!( + !paused + .has_changed() + .expect("elicitation service should remain available") + ); + } +} + #[tokio::test] async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index cb08cc5e13ea..a7271f3ec801 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -254,7 +254,8 @@ impl RequestPluginInstallHandler { request_id, request, ) - .await; + .await + .map_err(|error| FunctionCallError::RespondToModel(error.to_string()))?; let response = elicitation.response; if let Some(response) = response.as_ref() { maybe_persist_disabled_install_request(&session, &turn, &tool, response).await; diff --git a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs index c6024968faa3..219ce0fc7cc2 100644 --- a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs +++ b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs @@ -1,9 +1,10 @@ -//! Verify MCP auth prompts and elicitation analytics through actual turns. +//! Verify MCP prompts, non-root refusal, and elicitation analytics through actual turns. use anyhow::Result; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol as app; +use codex_core::StartThreadOptions; use codex_core::TurnInputRequest; use codex_core::TurnInputSubmission; use codex_core::config::Constrained; @@ -14,13 +15,19 @@ use codex_extension_api::ToolLifecycleFuture; use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::TurnItem; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ElicitationAction; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::PathExt; use core_test_support::apps_test_server::AppsTestServer; @@ -31,6 +38,8 @@ use core_test_support::apps_test_server::recorded_apps_tool_calls; use core_test_support::apps_test_server::search_capable_apps_builder; use core_test_support::responses; use core_test_support::skip_if_no_network; +use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -50,44 +59,63 @@ const CALL_ID: &str = "calendar-elicitation-call"; const PRIVATE_SENTINEL: &str = "synthetic-private-value"; #[derive(Clone, Copy)] -struct AuthFailureResponder(Scenario); +enum AuthFailureFormat { + Text, + Structured, +} -impl Respond for AuthFailureResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let body: Value = - serde_json::from_slice(&request.body).expect("tools/call request should be valid JSON"); - let id = body.get("id").cloned().unwrap_or(Value::Null); +#[derive(Clone, Copy)] +struct AuthFailureResponder { + scenario: Scenario, + format: AuthFailureFormat, +} +impl AuthFailureResponder { + fn result(self) -> Value { let mut response = json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "content": [{ - "type": "text", - "text": PRIVATE_SENTINEL, - }], - "isError": true, - "_meta": { - "_codex_apps": { - "connector_auth_failure": { - "is_auth_failure": true, - "auth_reason": "reauthentication_required", - "connector_id": "calendar", - "link_id": "link_123", - "error_code": "UNAUTHORIZED", - "error_http_status_code": 401, - "error_action": "TRIGGER_REAUTHENTICATION", - }, + "content": [{ + "type": "text", + "text": PRIVATE_SENTINEL, + }], + "isError": true, + "_meta": { + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "auth_reason": "reauthentication_required", + "connector_id": "calendar", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", }, }, }, }); - if self.0 == Scenario::AuthMetadataRemoved { - response["result"]["_meta"] = json!({"_codex_apps": {"connector_auth_failure": { + if self.scenario == Scenario::AuthMetadataRemoved { + response["_meta"] = json!({"_codex_apps": {"connector_auth_failure": { "is_auth_failure": true, "connector_id": "calendar", }}}); } - ResponseTemplate::new(/*status*/ 200).set_body_json(response) + if matches!(self.format, AuthFailureFormat::Structured) { + response["structuredContent"] = json!({ + "error": "synthetic-structured-auth-failure", + "details": {"reason": "reauthentication_required", "status": 401}, + }); + } + response + } +} + +impl Respond for AuthFailureResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = + serde_json::from_slice(&request.body).expect("tools/call request should be valid JSON"); + ResponseTemplate::new(/*status*/ 200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": body.get("id").cloned().unwrap_or(Value::Null), + "result": self.result(), + })) } } @@ -141,7 +169,10 @@ async fn actual_turn_elicitation_analytics(scenario: Scenario) -> Result<()> { "method": "tools/call", "params": {"name": "calendar_create_event"}, }))) - .respond_with(AuthFailureResponder(scenario)) + .respond_with(AuthFailureResponder { + scenario, + format: AuthFailureFormat::Text, + }) .with_priority(/*p*/ 1) .mount(&server) .await; @@ -485,3 +516,207 @@ approvals_reviewer = "user" Ok(()) } + +#[derive(Clone, Copy)] +enum SubagentRequestKind { + ToolApproval, + LegacyToolApproval, + ConnectorAuth(AuthFailureFormat), +} + +#[test_case(SubagentRequestKind::ToolApproval, None; "child_tool_approval_is_rejected_before_execution")] +#[test_case(SubagentRequestKind::LegacyToolApproval, None; "child_legacy_tool_approval_is_rejected_before_execution")] +#[test_case(SubagentRequestKind::ConnectorAuth(AuthFailureFormat::Text), None; "child_connector_auth_preserves_text_diagnostics_and_requests_handoff")] +#[test_case(SubagentRequestKind::ConnectorAuth(AuthFailureFormat::Structured), None; "child_connector_auth_preserves_structured_diagnostics_and_requests_handoff")] +#[test_case(SubagentRequestKind::ConnectorAuth(AuthFailureFormat::Structured), Some(1); "child_connector_auth_structured_diagnostics_respect_output_limit")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn core_generated_mcp_elicitations_are_root_only( + kind: SubagentRequestKind, + tool_output_token_limit: Option, +) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let apps = AppsTestServer::mount_searchable(&server).await?; + if let SubagentRequestKind::ConnectorAuth(format) = kind { + Mock::given(method("POST")) + .and(path_regex("^/api/codex/ps/mcp/?$")) + .and(body_partial_json(json!({ + "method": "tools/call", + "params": {"name": "calendar_create_event"} + }))) + .respond_with(AuthFailureResponder { + scenario: Scenario::DefaultAuth, + format, + }) + .with_priority(/*p*/ 1) + .mount(&server) + .await; + } + let test = search_capable_apps_builder(apps.chatgpt_base_url) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::User; + config.tool_output_token_limit = tool_output_token_limit; + config + .features + .enable(Feature::AuthElicitation) + .expect("enable auth elicitation"); + config + .features + .set_enabled( + Feature::ToolCallMcpElicitation, + !matches!(kind, SubagentRequestKind::LegacyToolApproval), + ) + .expect("configure MCP approval transport"); + let approval_mode = match kind { + SubagentRequestKind::ToolApproval | SubagentRequestKind::LegacyToolApproval => { + "prompt" + } + SubagentRequestKind::ConnectorAuth(_) => "approve", + }; + let user_config = toml::from_str(&format!( + r#"[apps.calendar] +default_tools_approval_mode = "{approval_mode}" +approvals_reviewer = "user" +"# + )) + .expect("apps config"); + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&config.codex_home.join("config.toml").abs(), user_config) + .expect("apply apps config"); + }) + .build_with_auto_env(&server) + .await?; + let child = test + .thread_manager + .start_thread(StartThreadOptions { + session_source: Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: test.session_configured.thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + environments: Some(vec![test.executor_environment().selection().clone()]), + ..StartThreadOptions::new(test.config.clone()) + }) + .await? + .thread; + wait_for_mcp_server(&child, CODEX_APPS_MCP_SERVER_NAME).await?; + responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_function_call_with_namespace( + "calendar-call", + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &json!({"title": "Lunch", "starts_at": "2026-06-18T12:00:00Z"}).to_string(), + ), + responses::ev_completed("tool-response"), + ]), + ) + .await; + let follow_up = responses::mount_sse_once(&server, responses::sse_completed("done")).await; + child + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "Use [$calendar](app://calendar) to create a calendar event.".into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::OnRequest), + permission_profile: Some(PermissionProfile::Disabled), + ..Default::default() + }), + ) + .await?; + let mut completed_status = None; + let mut completed_result = None; + wait_for_event(&child, |event| { + assert!( + !matches!( + event, + EventMsg::ElicitationRequest(_) | EventMsg::RequestUserInput(_) + ), + "child MCP tool calls must not prompt the user" + ); + if let EventMsg::ItemCompleted(event) = event + && let TurnItem::McpToolCall(item) = &event.item + && item.id == "calendar-call" + { + completed_status = Some(item.status); + completed_result = item.result.clone(); + } + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + assert_eq!(completed_status, Some(McpToolCallStatus::Failed)); + assert_eq!( + recorded_apps_tool_calls(&server).await.len(), + usize::from(matches!(kind, SubagentRequestKind::ConnectorAuth(_))) + ); + let output = follow_up + .single_request() + .function_call_output("calendar-call"); + let output_text = match &output["output"] { + Value::String(text) => text.clone(), + Value::Array(items) => items + .iter() + .filter_map(|item| item["text"].as_str()) + .collect::>() + .join("\n"), + other => panic!("unexpected MCP output: {other}"), + }; + assert_eq!( + output_text.contains(MCP_ELICITATION_HANDOFF_MESSAGE), + tool_output_token_limit.is_none(), + "handoff guidance must use the ordinary tool-output limit" + ); + if tool_output_token_limit.is_some() { + assert!(output_text.contains("truncated") || output_text.contains("omitted")); + } + if let SubagentRequestKind::ConnectorAuth(format) = kind { + let original = AuthFailureResponder { + scenario: Scenario::DefaultAuth, + format, + } + .result(); + let completed_result = completed_result.expect("completed auth failure result"); + assert_eq!(completed_result.is_error, Some(true)); + assert_eq!(completed_result.meta, Some(original["_meta"].clone())); + assert_eq!(completed_result.structured_content, None); + + let mut expected_content = vec![json!({ + "type": "text", + "text": format!( + "Authentication for Calendar could not be completed. {MCP_ELICITATION_HANDOFF_MESSAGE}" + ), + })]; + expected_content.extend( + original["content"] + .as_array() + .expect("original content") + .clone(), + ); + if let Some(structured_content) = original.get("structuredContent") { + expected_content.push(json!({"type": "text", "text": structured_content.to_string()})); + } + assert_eq!(completed_result.content, expected_content); + for content in &expected_content { + let text = content["text"].as_str().expect("auth diagnostic text"); + assert_eq!( + output_text.contains(text), + tool_output_token_limit.is_none(), + "auth diagnostics and guidance must share the ordinary tool-output limit" + ); + } + assert!( + !output_text.contains("link_123"), + "private auth metadata must not reach the model" + ); + } + child.shutdown_and_wait().await?; + test.codex.shutdown_and_wait().await?; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mcp_auth_refresh.rs b/codex-rs/core/tests/suite/mcp_auth_refresh.rs index 6a9f3ec62cdb..64778694e04b 100644 --- a/codex-rs/core/tests/suite/mcp_auth_refresh.rs +++ b/codex-rs/core/tests/suite/mcp_auth_refresh.rs @@ -109,6 +109,7 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token( client_mcp_extensions: ClientMcpExtensions::default(), auth: Some(expected_auth.clone()), auth_manager: Some(Arc::clone(&auth_manager)), + allow_user_interaction: true, elicitation_reviewer: None, elicitation_lifecycle: None, }) diff --git a/codex-rs/core/tests/suite/mcp_subagent_elicitation.rs b/codex-rs/core/tests/suite/mcp_subagent_elicitation.rs new file mode 100644 index 000000000000..752379a98260 --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_subagent_elicitation.rs @@ -0,0 +1,288 @@ +//! MCP requests keep human input on the root and allow automatic approval in subagents. + +use anyhow::Result; +use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ResponsesRequest; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use test_case::test_case; +use wiremock::matchers::body_partial_json; + +const ROOT_ONLY_MESSAGE: &str = codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE; + +const SERVER: &str = r#" +import json +import sys + +def send(message): + print(json.dumps({"jsonrpc": "2.0", **message}), flush=True) + +elicitation = json.loads(sys.argv[1]) +pending = None +for line in sys.stdin: + request = json.loads(line) + method = request.get("method") + if method == "initialize": + result = {"protocolVersion": request["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "elicitation-test", "version": "1"}} + elif method == "tools/list": + result = {"tools": [{"name": "request_input", "inputSchema": {"type": "object", "properties": {}}, + "annotations": {"readOnlyHint": True}}]} + elif method == "tools/call": + pending = request["id"] + send({"id": "input-request", "method": "elicitation/create", "params": elicitation}) + continue + elif method is None and request.get("id") == "input-request": + send({"id": pending, "result": {"content": [{"type": "text", "text": json.dumps( + request.get("result", request.get("error")))}]}}) + continue + elif method == "resources/list": + result = {"resources": []} + elif method == "resources/templates/list": + result = {"resourceTemplates": []} + elif "id" not in request: + continue + else: + result = {} + send({"id": request["id"], "result": result}) +"#; + +#[derive(Clone, Copy)] +pub(super) enum Caller { + Root, + Subagent, + FullAccessSubagent, +} + +#[derive(Clone, Copy)] +pub(super) enum RequestKind { + BrowserAuth, + Permission, + StrictReview, +} + +#[test_case(Caller::FullAccessSubagent, RequestKind::BrowserAuth; "full_access_cannot_auto_accept_subagent_browser_auth")] +#[test_case(Caller::Root, RequestKind::BrowserAuth; "root_browser_auth_remains_interactive")] +#[test_case(Caller::FullAccessSubagent, RequestKind::Permission; "full_access_subagent_automatically_approves_permission")] +#[test_case(Caller::Subagent, RequestKind::StrictReview; "subagent_keeps_strict_automatic_safety_review")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_elicitations_keep_user_interaction_on_root( + caller: Caller, + request_kind: RequestKind, +) -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "the MCP fixture requires a host Python interpreter"); + mcp_server_elicitation_scenario(caller, request_kind).await?; + Ok(()) +} + +pub(super) async fn mcp_server_elicitation_scenario( + caller: Caller, + request_kind: RequestKind, +) -> Result> { + let server = responses::start_mock_server().await; + let test = test_codex().build_with_auto_env(&server).await?; + let mut elicitation = json!({ + "mode": "form", + "message": "Provide the requested input.", + "requestedSchema": {"type": "object", "properties": {}} + }); + match request_kind { + RequestKind::BrowserAuth => { + // Browser credentials travel through the browser broker, so the MCP schema is empty. + elicitation["_meta"] = json!({ + "codex_approval_kind": "browser_auth", + "browser_auth_challenge_id": "test_browser_auth_challenge_0123456789", + "origin": "https://example.com", + "reason": "Sign in to continue browsing.", + "fields": [{"id": "password", "label": "Password", "type": "password", "required": true}] + }); + } + RequestKind::Permission | RequestKind::StrictReview => { + elicitation["_meta"] = json!({ + "codex_request_type": "approval_request", + "codex_approval_kind": "mcp_tool_call", + "codex_strict_auto_review": matches!(request_kind, RequestKind::StrictReview), + "codex_sensitive_action": true, + "tool_name": "write_record", + "tool_params": {"value": 42} + }); + } + } + let mut config = test.config.clone(); + config.approvals_reviewer = ApprovalsReviewer::User; + config.mcp_servers.set(serde_json::from_value(json!({ + "elicitation": { + "command": if cfg!(windows) { "python" } else { "python3" }, + "args": ["-u", "-c", SERVER, elicitation.to_string()], + "default_tools_approval_mode": "approve" + } + }))?)?; + let expects_prompt = matches!(caller, Caller::Root); + let session_source = if expects_prompt { + SessionSource::Exec + } else { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: test.session_configured.thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }) + }; + let thread = test + .thread_manager + .start_thread(StartThreadOptions { + session_source: Some(session_source), + environments: Some(vec![test.executor_environment().selection().clone()]), + ..StartThreadOptions::new(config) + }) + .await? + .thread; + wait_for_mcp_server(&thread, "elicitation").await?; + let tool_call = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_function_call_with_namespace( + "input-call", + "mcp__elicitation", + "request_input", + "{}", + ), + responses::ev_completed("tool-response"), + ]), + ) + .await; + let guardian = if matches!(request_kind, RequestKind::StrictReview) { + Some( + 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 this action." + }) + .to_string(), + ), + responses::ev_completed("guardian-review"), + ]), + ) + .await, + ) + } else { + None + }; + let follow_up = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("done-message", "done"), + responses::ev_completed("done-response"), + ]), + ) + .await; + let full_access = matches!(caller, Caller::FullAccessSubagent); + thread + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "Run the input tool.".into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(if full_access { + AskForApproval::Never + } else { + AskForApproval::OnRequest + }), + permission_profile: Some(if full_access { + PermissionProfile::Disabled + } else { + PermissionProfile::read_only() + }), + ..Default::default() + }), + ) + .await?; + let mut prompts = 0; + loop { + let event = wait_for_event(&thread, |event| { + matches!( + event, + EventMsg::ElicitationRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::ElicitationRequest(request) = event else { + break; + }; + assert!( + expects_prompt, + "subagent MCP elicitations must not reach the user" + ); + assert_eq!(prompts, 0, "one tool call must not prompt twice"); + prompts += 1; + assert!( + follow_up.requests().is_empty(), + "the tool must wait for input" + ); + thread + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: ElicitationAction::Accept, + content: Some(json!({})), + meta: None, + }) + .await?; + } + assert_eq!(prompts, usize::from(expects_prompt)); + let output = follow_up + .single_request() + .function_call_output("input-call"); + let response: Value = serde_json::from_str( + output["output"][1]["text"] + .as_str() + .expect("MCP response must reach the model"), + )?; + let expected = if matches!(request_kind, RequestKind::StrictReview) { + json!({ + "action": "accept", "content": {}, + "_meta": {"approvals_reviewer": "auto_review"} + }) + } else if expects_prompt || full_access && matches!(request_kind, RequestKind::Permission) { + json!({"action": "accept", "content": {}}) + } else { + json!({"code": -32603, "message": ROOT_ONLY_MESSAGE}) + }; + assert_eq!(response, expected); + let mut requests = tool_call.requests(); + if let Some(guardian) = guardian { + assert!(guardian.single_request().body_contains_text("write_record")); + requests.extend(guardian.requests()); + } + requests.extend(follow_up.requests()); + thread.shutdown_and_wait().await?; + test.codex.shutdown_and_wait().await?; + Ok(requests) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f6e66f596648..e751f952ec35 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -113,6 +113,7 @@ mod mcp_optional_startup_grace; #[cfg(unix)] mod mcp_refresh_cleanup; mod mcp_startup_refresh_http_proxy; +mod mcp_subagent_elicitation; mod mcp_tool_cache; mod mcp_tool_exposure; mod mcp_turn_metadata; diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 27784a3ce71e..3ba7a0e24764 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -756,6 +756,30 @@ async fn astra_refreshes_plugin_tools_and_skills_in_an_existing_thread() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_browser_auth_returns_handoff_without_prompting() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "the MCP fixture requires a host Python interpreter"); + use super::mcp_subagent_elicitation::Caller; + use super::mcp_subagent_elicitation::RequestKind; + use super::mcp_subagent_elicitation::mcp_server_elicitation_scenario; + + let requests = + mcp_server_elicitation_scenario(Caller::Subagent, RequestKind::BrowserAuth).await?; + let snapshot = context_snapshot::format_request_history_snapshot( + "An MCP browser sign-in request fails in a subagent without prompting the user; the next model request contains guidance to ask the parent.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ); + let snapshot = regex_lite::Regex::new(r"Wall time: [0-9]+(?:\.[0-9]+)? seconds")? + .replace_all(&snapshot, "Wall time: seconds") + .into_owned(); + insta::assert_snapshot!("subagent_browser_auth_handoff", snapshot); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_checkpoint_migration_request_history() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__subagent_browser_auth_handoff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__subagent_browser_auth_handoff.snap new file mode 100644 index 000000000000..41d40d25ddc6 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__subagent_browser_auth_handoff.snap @@ -0,0 +1,40 @@ +--- +source: core/tests/suite/scenarios.rs +expression: snapshot +--- +Scenario: An MCP browser sign-in request fails in a subagent without prompting the user; the next model request contains guidance to ask the parent. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + instructions: + model: "gpt-5.5" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (10; hash=1d541727c70b02b8): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=cea8b3c4515459b0 + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer: + +01:message/user: + +02:message/user: + Run the input tool. +-- request 2 (turn) -- +03:function_call/mcp__elicitation.request_input:{} +04:function_call_output:Wall time: seconds + Output: | {"code": -32603, "message": "MCP server elicitations can only be requested by the root thread. Ask the p... until the parent confirms the blocker is resolved."} [hash=096a35c5742e4b6f] diff --git a/codex-rs/ext/mcp/src/event_stream_tests.rs b/codex-rs/ext/mcp/src/event_stream_tests.rs index 30ffcd5f6de7..c533a90211e5 100644 --- a/codex-rs/ext/mcp/src/event_stream_tests.rs +++ b/codex-rs/ext/mcp/src/event_stream_tests.rs @@ -176,6 +176,7 @@ impl Fixture { client_mcp_extensions: Default::default(), auth: Some(auth_value.clone()), auth_manager: Some(Arc::clone(&auth)), + allow_user_interaction: true, elicitation_reviewer: None, elicitation_lifecycle: None, }; diff --git a/codex-rs/protocol/src/mcp_approval_meta.rs b/codex-rs/protocol/src/mcp_approval_meta.rs index b5323708d8ab..1ed0f2f3fda3 100644 --- a/codex-rs/protocol/src/mcp_approval_meta.rs +++ b/codex-rs/protocol/src/mcp_approval_meta.rs @@ -3,7 +3,10 @@ /// form-forwarding safeguards. pub const APPROVAL_KIND_KEY: &str = "codex_approval_kind"; pub const APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; +pub const APPROVAL_KIND_BROWSER_AUTH: &str = "browser_auth"; pub const APPROVAL_KIND_TOOL_SUGGESTION: &str = "tool_suggestion"; +/// Marks requests that need user input even when their form schema is empty. +pub const REQUIRES_USER_INPUT_KEY: &str = "codex_requires_user_input"; pub const REQUEST_TYPE_KEY: &str = "codex_request_type"; pub const REQUEST_TYPE_APPROVAL_REQUEST: &str = "approval_request"; pub const STRICT_AUTO_REVIEW_KEY: &str = "codex_strict_auto_review"; From e22e6523eb5b87f1872e823fa6c97906c2fc671a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 01:08:56 +0000 Subject: [PATCH 006/373] Remove the `done` prefix from TUI completion timestamps (#46067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Render completion footers as `2:32 PM` or `Worked for 2m 5s · 2:32 PM`, with the existing date formatting for older completions. Update test helpers to identify completion footer cells by type when normalizing or excluding timestamps, preserving message text that resembles a completion footer. ## Testing Update completion label and transcript snapshots, and add assertions that timestamp normalization preserves ordinary message text while normalizing completion footer cells. GitOrigin-RevId: 35f1658db02f8527212c006fd7492b574a105304 --- .../tui/src/app/tests/safety_buffering.rs | 3 +- ...ow_answers_without_overlap_duplicates.snap | 6 +-- ..._safety_retry_committed_steer_history.snap | 2 +- codex-rs/tui/src/chatwidget/realtime_tests.rs | 14 +----- .../realtime_tests/handoff_privacy.rs | 9 ++-- .../src/chatwidget/realtime_tests/handoffs.rs | 27 ++++++----- .../realtime_tests/speech_recovery.rs | 21 ++++---- ...t_markdown_code_blocks_vt100_snapshot.snap | 2 +- ..._tests__completion_after_plain_answer.snap | 2 +- ...completion_after_streamed_tool_answer.snap | 2 +- ...ests__completion_like_transcript_text.snap | 14 +++--- ...ked_for_uses_cumulative_turn_duration.snap | 2 +- ...ests__slash_export_completion_message.snap | 2 +- ...ified_exec_non_empty_then_empty_after.snap | 2 +- ...d_exec_wait_after_final_agent_message.snap | 2 +- ...ec_wait_before_streamed_agent_message.snap | 2 +- ...ied_exec_waiting_multiple_empty_after.snap | 2 +- .../tui/src/chatwidget/tests/app_server.rs | 6 +-- .../tests/completion_styling_tests.rs | 35 ++++++++++---- .../tui/src/chatwidget/tests/exec_flow.rs | 48 ++++++++----------- codex-rs/tui/src/chatwidget/tests/helpers.rs | 32 +++++++++++-- .../src/chatwidget/tests/slash_commands.rs | 14 +++--- .../src/chatwidget/tests/status_and_layout.rs | 6 +-- codex-rs/tui/src/history_cell/separators.rs | 6 +-- .../tui/src/history_cell/separators_tests.rs | 40 ++++++++-------- 25 files changed, 157 insertions(+), 144 deletions(-) diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index 18ca7678e6c1..ddd0a9a33c10 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -923,7 +923,8 @@ goals = true .as_any() .is::() { - replayed_history.push_str(&normalize_completion_timestamps(rendered)); + replayed_history + .push_str(&normalize_completion_timestamps(cell.as_ref(), rendered)); } else { replayed_history.push_str(&rendered); } diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__pagination_completion_tests__older_pagination_completion_footers_follow_answers_without_overlap_duplicates.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__pagination_completion_tests__older_pagination_completion_footers_follow_answers_without_overlap_duplicates.snap index 7263cb06b5d8..5aa6a8f8db25 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__pagination_completion_tests__older_pagination_completion_footers_follow_answers_without_overlap_duplicates.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__pagination_completion_tests__older_pagination_completion_footers_follow_answers_without_overlap_duplicates.snap @@ -5,7 +5,7 @@ expression: "snapshots.join(\"\\n\\n--- next overlapping page ---\\n\\n\")" Earlier messages are available — press ctrl + t to view the full transcript • Middle answer - Worked for 2m 5s · done Sep 6, 2000 at 2:33 PM + Worked for 2m 5s · Sep 6, 2000 at 2:33 PM --- next overlapping page --- @@ -15,7 +15,7 @@ Earlier messages are available — press ctrl + t to view the full transcript • Oldest answer - Worked for 2m 5s · done Sep 6, 2000 at 2:32 PM + Worked for 2m 5s · Sep 6, 2000 at 2:32 PM › Middle prompt @@ -23,4 +23,4 @@ Earlier messages are available — press ctrl + t to view the full transcript • Middle answer - Worked for 2m 5s · done Sep 6, 2000 at 2:33 PM + Worked for 2m 5s · Sep 6, 2000 at 2:33 PM diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_retry_committed_steer_history.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_retry_committed_steer_history.snap index 0adc4a3857f0..d6b4a0503984 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_retry_committed_steer_history.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_retry_committed_steer_history.snap @@ -4,4 +4,4 @@ expression: rendered_retry --- › Handle the safety-buffered request Keep the accepted steer -⚠ Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.• done done [completion time] +⚠ Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.• done [completion time] diff --git a/codex-rs/tui/src/chatwidget/realtime_tests.rs b/codex-rs/tui/src/chatwidget/realtime_tests.rs index 37280b087ca9..2bb89b585add 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests.rs @@ -13,6 +13,7 @@ use crate::chatwidget::ChatWidget; use crate::chatwidget::ReplayKind; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::render_bottom_popup; +use crate::history_cell::FinalMessageSeparator; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; @@ -138,19 +139,6 @@ fn finish_turn( ); } -fn without_completion_metadata(rendered: &str) -> String { - rendered - .lines() - .filter_map(|line| { - let stable = line - .split_once(" done ") - .map_or(line, |(before, _)| before); - (!stable.is_empty()).then_some(stable) - }) - .collect::>() - .join("\n") -} - #[path = "realtime_tests/caption_replay.rs"] mod caption_replay; #[path = "realtime_tests/handoff_privacy.rs"] diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs b/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs index 4a9c0ebb5347..3afea6c54bea 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs @@ -107,7 +107,9 @@ async fn typed_steering_restores_normal_output_for_an_existing_voice_item() { let mut rendered = String::new(); while let Ok(event) = events.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { + if let AppEvent::InsertHistoryCell(cell) = event + && !cell.as_any().is::() + { for line in cell.display_lines(/*width*/ 80) { rendered.push_str(&line.to_string()); } @@ -115,10 +117,7 @@ async fn typed_steering_restores_normal_output_for_an_existing_voice_item() { } assert!(rendered.contains("typed answer"), "{rendered}"); assert!(!rendered.contains("private voice commentary"), "{rendered}"); - insta::assert_snapshot!( - "typed_steering_keeps_voice_commentary_private", - without_completion_metadata(&rendered) - ); + insta::assert_snapshot!("typed_steering_keeps_voice_commentary_private", rendered); assert!(ops.try_recv().is_err()); } diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs index 062a021f12e0..e3266da9e6e4 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs @@ -156,7 +156,9 @@ async fn delegation_started_before_peer_connection_keeps_its_voice_origin() { let mut rendered_history = Vec::new(); commit_realtime_history_events(&mut chat, &mut events); while let Ok(event) = events.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { + if let AppEvent::InsertHistoryCell(cell) = event + && !cell.as_any().is::() + { let rendered = cell .transcript_lines(/*width*/ 80) .iter() @@ -169,7 +171,7 @@ async fn delegation_started_before_peer_connection_keeps_its_voice_origin() { } insta::assert_snapshot!( "voice_delegation_during_connection", - without_completion_metadata(&rendered_history.join("\n")) + rendered_history.join("\n") ); } @@ -322,21 +324,20 @@ async fn explicit_final_answer_can_explain_private_channel_markers() { commit_realtime_history_events(&mut chat, &mut events); let rendered = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => Some( - cell.transcript_lines(/*width*/ 80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"), - ), + AppEvent::InsertHistoryCell(cell) if !cell.as_any().is::() => { + Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ) + } _ => None, }) .collect::>() .join("\n"); - insta::assert_snapshot!( - "explicit_final_answer_with_channel_marker", - without_completion_metadata(&rendered) - ); + insta::assert_snapshot!("explicit_final_answer_with_channel_marker", rendered); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs b/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs index 7c3f33ed1534..6cf44f84c0d8 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs @@ -401,22 +401,21 @@ async fn answer_exceeding_speech_budget_is_shown_in_full_instead() { assert!(chat.realtime_conversation.pending_speech.is_empty()); let rendered = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => Some( - cell.transcript_lines(/*width*/ 80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"), - ), + AppEvent::InsertHistoryCell(cell) if !cell.as_any().is::() => { + Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ) + } _ => None, }) .collect::>() .join("\n"); assert_eq!( - without_completion_metadata(&rendered) - .split_whitespace() - .collect::>() - .join(" "), + rendered.split_whitespace().collect::>().join(" "), format!("• {text}") .split_whitespace() .collect::>() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap index b7bc04285f07..1cf72689d0fb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -50,4 +50,4 @@ expression: "normalize_completion_timestamps(&\n(normalize_snapshot_paths(term.b "path": "C:\\Program Files\\App", "regex": "^foo.*(bar)?$" } - done [completion time] + [completion time] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_plain_answer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_plain_answer.snap index 0df5a3f92f82..6cb6d51d2c98 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_plain_answer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_plain_answer.snap @@ -4,4 +4,4 @@ expression: normalize_completion_timestamps(& (text)) --- • The change is ready. - Worked for [duration] · done [completion time] + Worked for [duration] · [completion time] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_streamed_tool_answer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_streamed_tool_answer.snap index 197fe805ec3e..a546d2a8495c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_streamed_tool_answer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_after_streamed_tool_answer.snap @@ -7,4 +7,4 @@ expression: normalize_completion_timestamps(& (text)) • The change is ready. - Worked for [duration] · done [completion time] + Worked for [duration] · [completion time] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_like_transcript_text.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_like_transcript_text.snap index edbc8534896a..795b17b96e50 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_like_transcript_text.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completion_like_transcript_text.snap @@ -2,12 +2,12 @@ source: tui/src/chatwidget/tests/completion_styling_tests.rs expression: "&transcript" --- -› done Sep 6, 2000 at 2:32 PM -• done Sep 6, 2000 at 2:32 PM - └ done Sep 6, 2000 at 2:32 PM +› Sep 6, 2000 at 2:32 PM +• Sep 6, 2000 at 2:32 PM + └ Sep 6, 2000 at 2:32 PM The job was done Sep 6, 2000 at 2:32 PM - done Sep 6, 2000 at 2:32 PM is the expected text - Worked for 2m 5s · done Sep 6, 2000 at 2:32 PM is quoted + Sep 6, 2000 at 2:32 PM is the expected text + Worked for 2m 5s · Sep 6, 2000 at 2:32 PM is quoted - done 3:24 PM - Worked for 1h 2m 3s · done Sep 6, 2000 at 2:32 PM + 3:24 PM + Worked for 1h 2m 3s · Sep 6, 2000 at 2:32 PM diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_worked_for_uses_cumulative_turn_duration.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_worked_for_uses_cumulative_turn_duration.snap index 4d4c170c7182..a420630cb329 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_worked_for_uses_cumulative_turn_duration.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_worked_for_uses_cumulative_turn_duration.snap @@ -7,4 +7,4 @@ expression: normalize_completion_timestamps(& (combined)) • Final response. - Worked for [duration] · done [completion time] + Worked for [duration] · [completion time] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_completion_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_completion_message.snap index 1cb3a9b91ff7..09c60318a476 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_completion_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_completion_message.snap @@ -2,7 +2,7 @@ source: tui/src/chatwidget/tests/slash_commands.rs expression: "normalize_completion_timestamps(&\n(cells.iter().map(|cell|\nlines_to_single_string(cell).trim().to_string()).collect::>().join(\"\\n\")))" --- -done [completion time] +[completion time] • Saved conversation to conversation.md • Copied conversation to clipboard ■ Copy failed: clipboard unavailable diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap index 5fc634ac57c8..47da08676a5e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap @@ -7,4 +7,4 @@ expression: normalize_completion_timestamps(& (combined)) • Waited for background terminal · just fix - done [completion time] + [completion time] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap index 82e8ca4712e0..3c7394baec46 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap @@ -6,4 +6,4 @@ expression: normalize_completion_timestamps(& (combined)) • Final response. - done [completion time] + [completion time] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap index d1aebded5047..6787e583658e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap @@ -6,4 +6,4 @@ expression: normalize_completion_timestamps(& (combined)) • Streaming response. - done [completion time] + [completion time] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap index c6c442d506fc..5cb828b5d102 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap @@ -4,4 +4,4 @@ expression: normalize_completion_timestamps(& (combined)) --- • Waited for background terminal · just fix - done [completion time] + [completion time] diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index c807212042e2..c8f36ff0d202 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -826,11 +826,11 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( /*replay_kind*/ None, ); - let completion_cells = drain_insert_history(&mut rx) + let completion_cells = drain_insert_history_normalized(&mut rx) .iter() - .map(|lines| normalize_completion_timestamps(lines_to_single_string(lines).trim())) + .map(|lines| lines_to_single_string(lines).trim().to_string()) .collect::>(); - assert_eq!(completion_cells, vec!["done [completion time]"]); + assert_eq!(completion_cells, vec!["[completion time]"]); assert!(!chat.bottom_pane.is_task_running()); assert!(chat.bottom_pane.status_widget().is_none()); assert_eq!( diff --git a/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs b/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs index afbe3f645a84..ae9da4d2f007 100644 --- a/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs @@ -56,7 +56,7 @@ fn saved_completion_label() -> String { Local .timestamp_opt(COMPLETED_AT, /*nsecs*/ 0) .unwrap() - .format("done %b %-d, %Y at %-I:%M %p") + .format("%b %-d, %Y at %-I:%M %p") .to_string() } @@ -73,7 +73,7 @@ async fn completion_follows_plain_and_streamed_tool_answers() { } complete_turn(&mut chat, completed_turn(Some(125_000), Some(COMPLETED_AT))); - let text = drain_insert_history(&mut rx) + let text = drain_insert_history_normalized(&mut rx) .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); @@ -83,7 +83,7 @@ async fn completion_follows_plain_and_streamed_tool_answers() { } else { "completion_after_plain_answer" }, - normalize_completion_timestamps(text), + text, ); } } @@ -109,7 +109,7 @@ async fn completion_live_applies_duration_threshold_and_preserves_timestamp_fall let time = if completed_at.is_some() { saved_completion_label() } else { - time.format("done %-I:%M %p").to_string() + time.format("%-I:%M %p").to_string() }; format!("{prefix}{time}") }); @@ -192,7 +192,7 @@ async fn completion_replay_waits_for_older_turn_items_to_load() { let older_time = Local .timestamp_opt(older_completed_at, /*nsecs*/ 0) .unwrap() - .format("done %b %-d, %Y at %-I:%M %p"); + .format("%b %-d, %Y at %-I:%M %p"); assert_eq!( completion_labels(&mut rx), format!("Worked for 2m 5s · {older_time}"), @@ -228,14 +228,31 @@ async fn completion_failed_and_interrupted_turns_do_not_report_success() { } #[test] -fn completion_snapshot_normalization_is_explicit_and_line_scoped() { +fn completion_snapshot_normalization_preserves_clock_only_message_lines() { let timestamp = "Sep 6, 2000 at 2:32 PM"; let transcript = format!( - "› done {timestamp}\n• done {timestamp}\n └ done {timestamp}\n The job was done {timestamp}\n done {timestamp} is the expected text\n Worked for 2m 5s · done {timestamp} is quoted\n\n done 3:24 PM\n Worked for 1h 2m 3s · done {timestamp}\n" + "› {timestamp}\n• {timestamp}\n └ {timestamp}\n The job was done {timestamp}\n {timestamp} is the expected text\n Worked for 2m 5s · {timestamp} is quoted\n\n 3:24 PM\n Worked for 1h 2m 3s · {timestamp}\n" + ); + let message = history_cell::PlainHistoryCell::new( + transcript + .lines() + .map(|line| Line::from(line.to_owned())) + .collect(), ); assert_chatwidget_snapshot!("completion_like_transcript_text", &transcript); assert_eq!( - normalize_completion_timestamps(&transcript), - "› done Sep 6, 2000 at 2:32 PM\n• done Sep 6, 2000 at 2:32 PM\n └ done Sep 6, 2000 at 2:32 PM\n The job was done Sep 6, 2000 at 2:32 PM\n done Sep 6, 2000 at 2:32 PM is the expected text\n Worked for 2m 5s · done Sep 6, 2000 at 2:32 PM is quoted\n\n done [completion time]\n Worked for [duration] · done [completion time]\n" + normalize_completion_timestamps(&message, &transcript), + transcript + ); + + let footer = + history_cell::FinalMessageSeparator::new(Some(3_723), /*runtime_metrics*/ None) + .with_completed_at(Local.timestamp_opt(COMPLETED_AT, /*nsecs*/ 0).unwrap()); + assert_eq!( + normalize_completion_timestamps( + &footer, + lines_to_single_string(&footer.display_lines(/*width*/ 80)) + ), + " Worked for [duration] · [completion time]\n" ); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 498f84b607ec..8df2eae2c43c 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -888,15 +888,12 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { complete_assistant_message(&mut chat, "msg-1", "Final response.", /*phase*/ None); handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_normalized(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_chatwidget_snapshot!( - "unified_exec_wait_after_final_agent_message", - normalize_completion_timestamps(combined) - ); + assert_chatwidget_snapshot!("unified_exec_wait_after_final_agent_message", combined); } #[tokio::test] @@ -915,15 +912,12 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { handle_agent_message_delta(&mut chat, "Streaming response."); handle_turn_completed(&mut chat, "turn-wait-1", /*duration_ms*/ None); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_normalized(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_chatwidget_snapshot!( - "unified_exec_wait_before_streamed_agent_message", - normalize_completion_timestamps(combined) - ); + assert_chatwidget_snapshot!("unified_exec_wait_before_streamed_agent_message", combined); } #[tokio::test] @@ -954,19 +948,21 @@ async fn final_worked_for_uses_cumulative_turn_duration_snapshot() { ); handle_turn_completed(&mut chat, "turn-1", duration_ms); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_with(&mut rx, |cell| { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + if cell.as_any().is::() { + assert!(rendered.contains("Worked for 2m 5s"), "{rendered}"); + } + normalize_completion_timestamps(cell, rendered) + .lines() + .map(|line| Line::from(line.to_owned())) + .collect() + }); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert!( - combined.contains("Worked for 2m 5s"), - "expected final separator to use cumulative turn duration, got:\n{combined}" - ); - assert_chatwidget_snapshot!( - "final_worked_for_uses_cumulative_turn_duration", - normalize_completion_timestamps(combined) - ); + assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined); } } @@ -1033,15 +1029,12 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { handle_turn_completed(&mut chat, "turn-wait-3", /*duration_ms*/ None); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_normalized(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_chatwidget_snapshot!( - "unified_exec_waiting_multiple_empty_after", - normalize_completion_timestamps(combined) - ); + assert_chatwidget_snapshot!("unified_exec_waiting_multiple_empty_after", combined); } #[tokio::test] @@ -1108,7 +1101,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); - let post_cells = drain_insert_history(&mut rx); + let post_cells = drain_insert_history_normalized(&mut rx); let mut combined = pre_cells .iter() .map(|lines| lines_to_single_string(lines)) @@ -1121,10 +1114,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { combined.push('\n'); } combined.push_str(&post); - assert_chatwidget_snapshot!( - "unified_exec_non_empty_then_empty_after", - normalize_completion_timestamps(combined) - ); + assert_chatwidget_snapshot!("unified_exec_non_empty_then_empty_after", combined); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 0f4d5a6aeb2d..86d44c9eb75e 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -345,13 +345,29 @@ pub(super) fn drain_insert_history( drain_insert_history_with(rx, |cell| cell.display_lines(/*width*/ 80)) } +pub(super) fn drain_insert_history_normalized( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Vec>> { + drain_insert_history_with(rx, |cell| { + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|mut line| { + if cell.as_any().is::() { + line.spans = vec![normalize_completion_timestamps(cell, &line).into()]; + } + line + }) + .collect() + }) +} + pub(super) fn drain_insert_history_transcript( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec>> { drain_insert_history_with(rx, |cell| cell.transcript_lines(/*width*/ 80)) } -fn drain_insert_history_with( +pub(super) fn drain_insert_history_with( rx: &mut tokio::sync::mpsc::UnboundedReceiver, render: impl Fn(&dyn HistoryCell) -> Vec>, ) -> Vec>> { @@ -1768,11 +1784,17 @@ pub(super) async fn assert_hook_events_snapshot( assert_chatwidget_snapshot!(snapshot_name, combined); } -/// Normalize complete footer lines only, in snapshots that opt into clock normalization. -pub(crate) fn normalize_completion_timestamps(value: impl std::fmt::Display) -> String { +/// Normalize timestamps only in structurally identified completion footer cells. +pub(crate) fn normalize_completion_timestamps( + cell: &dyn HistoryCell, + value: impl std::fmt::Display, +) -> String { + if !cell.as_any().is::() { + return value.to_string(); + } static COMPLETION_FOOTER: std::sync::LazyLock = std::sync::LazyLock::new( || { - regex_lite::Regex::new(r"(?m)^(?P[ \t]*)(?PWorked for (?:[0-9]+h )?(?:[0-9]+m )?[0-9]+s · )?done (?:[A-Z][a-z]{2} [0-9]{1,2}(?:, [0-9]{4})? at )?[0-9]{1,2}:[0-9]{2} (?:AM|PM)(?P[ \t]*)$") + regex_lite::Regex::new(r"(?m)^(?P[ \t]*)(?PWorked for (?:[0-9]+h )?(?:[0-9]+m )?[0-9]+s · )?(?:[A-Z][a-z]{2} [0-9]{1,2}(?:, [0-9]{4})? at )?[0-9]{1,2}:[0-9]{2} (?:AM|PM)(?P[ \t]*)$") .expect("valid completion footer pattern") }, ); @@ -1785,7 +1807,7 @@ pub(crate) fn normalize_completion_timestamps(value: impl std::fmt::Display) -> } else { "" }; - format!("{indent}{duration}done [completion time]{padding}") + format!("{indent}{duration}[completion time]{padding}") }) .into_owned() } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 6dcc09bf7e4c..7056100445b0 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2180,16 +2180,14 @@ async fn slash_export_opens_destination_picker() { } chat.add_error_message("Copy failed: clipboard unavailable".to_string()); chat.add_error_message("Export failed: missing parent".to_string()); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_normalized(&mut rx); assert_chatwidget_snapshot!( "slash_export_completion_message", - normalize_completion_timestamps( - cells - .iter() - .map(|cell| lines_to_single_string(cell).trim().to_string()) - .collect::>() - .join("\n") - ), + cells + .iter() + .map(|cell| lines_to_single_string(cell).trim().to_string()) + .collect::>() + .join("\n"), ); } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 34204addcdbb..94e21545360c 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -5806,16 +5806,14 @@ printf 'fenced within fenced\n' // Finalize the stream without sending a final AgentMessage, to flush any tail. handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); - for lines in drain_insert_history(&mut rx) { + for lines in drain_insert_history_normalized(&mut rx) { crate::insert_history::insert_history_lines(&mut term, lines) .expect("Failed to insert history lines in test"); } assert_chatwidget_snapshot!( "chatwidget_markdown_code_blocks_vt100_snapshot", - normalize_completion_timestamps(normalize_snapshot_paths( - term.backend().vt100().screen().contents() - )) + normalize_snapshot_paths(term.backend().vt100().screen().contents()) ); } diff --git a/codex-rs/tui/src/history_cell/separators.rs b/codex-rs/tui/src/history_cell/separators.rs index 2ac840301dd4..201958160354 100644 --- a/codex-rs/tui/src/history_cell/separators.rs +++ b/codex-rs/tui/src/history_cell/separators.rs @@ -65,11 +65,11 @@ impl FinalMessageSeparator { } if let Some(completed_at) = self.completed_at { let format = if completed_at.date_naive() == today { - "done %-I:%M %p" + "%-I:%M %p" } else if completed_at.year() == today.year() { - "done %b %-d at %-I:%M %p" + "%b %-d at %-I:%M %p" } else { - "done %b %-d, %Y at %-I:%M %p" + "%b %-d, %Y at %-I:%M %p" }; label_parts.push(completed_at.format(format).to_string()); } diff --git a/codex-rs/tui/src/history_cell/separators_tests.rs b/codex-rs/tui/src/history_cell/separators_tests.rs index 4ea0e7de79c9..b727fb1b3d8f 100644 --- a/codex-rs/tui/src/history_cell/separators_tests.rs +++ b/codex-rs/tui/src/history_cell/separators_tests.rs @@ -40,13 +40,13 @@ fn completion_label_shows_duration_only_above_sixty_seconds() { .collect::>(); insta::assert_snapshot!(labels.join("\n"), @" - done 2:32 PM - done 2:32 PM - done 2:32 PM - done 2:32 PM - Worked for 1m 1s · done 2:32 PM - Worked for 2m 5s · done 2:32 PM - Worked for 1h 0m 5s · done 2:32 PM + 2:32 PM + 2:32 PM + 2:32 PM + 2:32 PM + Worked for 1m 1s · 2:32 PM + Worked for 2m 5s · 2:32 PM + Worked for 1h 0m 5s · 2:32 PM "); } @@ -60,9 +60,9 @@ fn completion_label_includes_date_when_viewed_on_another_day() { .with_completed_at(completed_at); let tomorrow = completed_at.date_naive().succ_opt().expect("next day"); - insta::assert_snapshot!(cell.label(tomorrow).expect("completion label"), @"Worked for 2m 5s · done Sep 6 at 2:32 PM"); + insta::assert_snapshot!(cell.label(tomorrow).expect("completion label"), @"Worked for 2m 5s · Sep 6 at 2:32 PM"); let next_year = tomorrow.with_year(/*year*/ 2001).expect("valid next year"); - insta::assert_snapshot!(cell.label(next_year).expect("completion label"), @"Worked for 2m 5s · done Sep 6, 2000 at 2:32 PM"); + insta::assert_snapshot!(cell.label(next_year).expect("completion label"), @"Worked for 2m 5s · Sep 6, 2000 at 2:32 PM"); } #[test] @@ -75,9 +75,9 @@ fn completion_uses_twelve_hour_time_at_midnight_noon_and_afternoon() { .expect("completion label") }); insta::assert_snapshot!(labels.join("\n"), @" - done 12:32 AM - done 12:32 PM - done 3:32 PM + 12:32 AM + 12:32 PM + 3:32 PM "); } @@ -115,13 +115,13 @@ fn completion_wraps_metadata_and_preserves_unwrapped_raw_text() { let lines = cell.display_lines(/*width*/ 24); let rendered = lines.iter().map(ToString::to_string).collect::>(); - insta::assert_snapshot!(rendered.join("\n"), @" - Worked for 2m 5s · - done Sep 6, 2000 at - 2:32 PM · Local tools: - 3 calls (2.5s) + insta::assert_snapshot!(rendered.join("\n"), @r" + Worked for 2m 5s · Sep + 6, 2000 at 2:32 PM · + Local tools: 3 calls + (2.5s) "); - insta::assert_snapshot!(cell.raw_lines()[0].to_string(), @"Worked for 2m 5s · done Sep 6, 2000 at 2:32 PM · Local tools: 3 calls (2.5s)"); + insta::assert_snapshot!(cell.raw_lines()[0].to_string(), @"Worked for 2m 5s · Sep 6, 2000 at 2:32 PM · Local tools: 3 calls (2.5s)"); for width in [0, 1, 5, 24] { assert!( cell.display_lines(width) @@ -163,6 +163,6 @@ fn completion_rendering_uses_the_captured_display_date() { .with_completed_at(completed_at); cell.display_date = completed_at.date_naive(); - insta::assert_snapshot!(cell.display_lines(/*width*/ 80)[0].to_string(), @" done 2:32 PM"); - assert_eq!(cell.raw_lines(), vec![Line::from("done 2:32 PM")]); + insta::assert_snapshot!(cell.display_lines(/*width*/ 80)[0].to_string(), @" 2:32 PM"); + assert_eq!(cell.raw_lines(), vec![Line::from("2:32 PM")]); } From 172f8a2901119ceab4d8552e906ea65d98626076 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 01:09:34 +0000 Subject: [PATCH 007/373] Use syntax theme colors for inline code and file paths (#46069) ## Why Inline code and local file paths in the TUI used a fixed cyan foreground regardless of the active syntax theme. ## What changed Use the active theme's Markdown raw-text foreground for inline code and local file paths, with cyan as the fallback when no matching color is available. Isolate the active theme and its revision per test thread to prevent parallel tests from affecting each other's rendering. ## Testing Add rendering snapshots for dark, light, ANSI, and fallback themes, plus a regression test for theme and revision isolation between test threads. GitOrigin-RevId: 40d84131d0abc9f177019d9b0b3e19536d5cd553 --- codex-rs/tui/src/markdown_render.rs | 18 ++- ...y_and_fallback_presentations_snapshot.snap | 4 +- codex-rs/tui/src/markdown_render_tests.rs | 135 +++++++++++++----- codex-rs/tui/src/render/highlight.rs | 60 ++++++-- .../tui/src/render/highlight_streaming.rs | 6 +- ...de_and_file_paths_follow_syntax_theme.snap | 66 +++++++++ ...els_have_a_visible_underline_snapshot.snap | 2 +- ...er__tests__incremental_file_citations.snap | 4 +- 8 files changed, 247 insertions(+), 48 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__inline_code_and_file_paths_follow_syntax_theme.snap diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 759fd9c3f4c1..43a111ee7673 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -35,10 +35,13 @@ //! unusably short chunks, expansive cells form tall narrow strips across enough //! body rows, or even 3-char-wide columns cannot fit, body rows render as //! key/value records. +//! +//! Inline code and local file paths share the active syntax theme's raw-markup foreground. use crate::markdown_text_merge::DecodedTextMerge; use crate::render::highlight::current_syntax_theme; use crate::render::highlight::foreground_style_for_scopes; +use crate::render::highlight::foreground_style_for_scopes_with_theme; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; use crate::style::table_separator_style; @@ -109,6 +112,12 @@ struct MarkdownStyles { impl Default for MarkdownStyles { fn default() -> Self { + Self::for_theme(&crate::render::highlight::current_syntax_theme()) + } +} + +impl MarkdownStyles { + fn for_theme(theme: &syntect::highlighting::Theme) -> Self { Self { h1: Style::new().bold().underlined(), h2: Style::new().bold(), @@ -116,7 +125,14 @@ impl Default for MarkdownStyles { h4: Style::new().italic(), h5: Style::new().italic(), h6: Style::new().italic(), - code: Style::new().cyan(), + code: foreground_style_for_scopes_with_theme( + theme, + &[ + "markup.inline.raw.string.markdown", + "markup.raw.inline.markdown", + ], + ) + .unwrap_or_else(|| Style::new().cyan()), emphasis: Style::new().italic(), strong: Style::new().bold(), strikethrough: Style::new().crossed_out(), diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__web_links__tests__label_only_and_fallback_presentations_snapshot.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__web_links__tests__label_only_and_fallback_presentations_snapshot.snap index 7d84cf65fe9d..ae0bffb9d5e3 100644 --- a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__web_links__tests__label_only_and_fallback_presentations_snapshot.snap +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__web_links__tests__label_only_and_fallback_presentations_snapshot.snap @@ -8,7 +8,7 @@ expression: presentations Span::from("plain "), Span::from("plain").cyan().underlined(), Span::from(" "), - Span::from("code").cyan(), + Span::from("code").fg(Color::Rgb(166, 227, 161)), Span::from(" "), Span::from("code").cyan().underlined(), ]), @@ -31,7 +31,7 @@ expression: presentations Span::from("https://example.com").cyan().underlined(), Span::from(")"), Span::from(" "), - Span::from("code").cyan(), + Span::from("code").fg(Color::Rgb(166, 227, 161)), ]), Line::from_iter([ Span::from("code").cyan().underlined(), diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index b2e7ec8f3f9c..b1c08d528e50 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -1,3 +1,4 @@ +use super::MarkdownStyles; use pretty_assertions::assert_eq; use ratatui::style::Modifier; use ratatui::style::Stylize; @@ -711,10 +712,60 @@ fn ordered_item_with_indented_continuation_is_tight() { #[test] fn inline_code() { let text = render_markdown_text("Example of `Inline code`"); - let expected = Line::from_iter(["Example of ".into(), "Inline code".cyan()]).into(); + let expected = Line::from_iter([ + "Example of ".into(), + Span::styled("Inline code", MarkdownStyles::default().code), + ]) + .into(); assert_eq!(text, expected); } +#[test] +fn inline_code_and_file_paths_follow_syntax_theme() { + let markdown = "Use `src/main.rs` and [src/lib.rs](./src/lib.rs)."; + let mut rendered = Vec::new(); + for name in [ + "catppuccin-mocha", + "catppuccin-latte", + "ansi", + "no-markup-color", + ] { + let theme = if name == "no-markup-color" { + syntect::highlighting::Theme::default() + } else { + crate::render::highlight::resolve_theme_by_name(name, /*codex_home*/ None) + .expect("bundled theme") + }; + let styles = MarkdownStyles::for_theme(&theme); + let code_style = styles.code; + let mut writer = super::Writer::new( + markdown, + pulldown_cmark::Parser::new(markdown).into_offset_iter(), + /*wrap_width*/ Some(24), + /*cwd*/ None, + &|_| false, + ); + writer.styles = styles; + writer.run(); + let lines = crate::terminal_hyperlinks::visible_lines(writer.text); + let paths = lines + .iter() + .flat_map(|line| &line.spans) + .filter(|span| matches!(span.content.as_ref(), "src/main.rs" | "src/lib.rs")) + .cloned() + .collect::>(); + assert_eq!( + paths, + vec![ + Span::styled("src/main.rs", code_style), + Span::styled("src/lib.rs", code_style), + ], + ); + rendered.push((name, lines)); + } + assert_debug_snapshot!(rendered); +} + #[test] fn strong() { assert_eq!( @@ -779,7 +830,7 @@ fn web_link_labels_use_link_style_and_preserve_inline_formatting() { "https://example.com".cyan().underlined(), ")".into(), " after ".into(), - "code".cyan(), + Span::styled("code", MarkdownStyles::default().code), ])); assert_eq!(text, expected, "label: {label}"); } @@ -828,7 +879,10 @@ fn file_link_hides_destination() { "[/Users/example/code/codex/codex-rs/tui/src/My%20File.rs](/Users/example/code/codex/codex-rs/tui/src/My%20File.rs)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter(["codex-rs/tui/src/My File.rs".cyan()])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/My File.rs", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -840,11 +894,14 @@ fn file_link_keeps_descriptive_label_and_target() { ); let expected = Text::from(Line::from_iter([ "Your ".into(), - "codex".cyan(), + Span::styled("codex", MarkdownStyles::default().code), " launcher ".into(), "automatically adds those overrides".into(), " (".into(), - "project/dotslash-gen/bin/codex:1105".cyan(), + Span::styled( + "project/dotslash-gen/bin/codex:1105", + MarkdownStyles::default().code, + ), ")".into(), ", even though you did not specify any.".into(), ])); @@ -921,7 +978,10 @@ fn file_link_decodes_percent_encoded_bare_path_destination() { let expected = Text::from(Line::from_iter([ "open Example Folder/Résumé/report.md".into(), " (".into(), - "Example Folder/Résumé/report.md".cyan(), + Span::styled( + "Example Folder/Résumé/report.md", + MarkdownStyles::default().code, + ), ")".into(), ])); assert_eq!(text, expected); @@ -952,7 +1012,7 @@ fn file_link_keeps_unrelated_relative_label_with_matching_suffix() { let expected = Text::from(Line::from_iter([ "other/src/lib.rs".into(), " (".into(), - "src/lib.rs".cyan(), + Span::styled("src/lib.rs", MarkdownStyles::default().code), ")".into(), ])); assert_eq!(text, expected); @@ -965,9 +1025,10 @@ fn file_link_appends_line_number_when_label_lacks_it() { "[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter([ - "codex-rs/tui/src/markdown_render.rs:74".cyan() - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/markdown_render.rs:74", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -977,9 +1038,10 @@ fn file_link_keeps_absolute_paths_outside_cwd() { "[README.md:74](/Users/example/code/codex/README.md:74)", Path::new("/Users/example/code/codex/codex-rs/tui"), ); - let expected = Text::from(Line::from_iter([ - "/Users/example/code/codex/README.md:74".cyan() - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "/Users/example/code/codex/README.md:74", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -1010,9 +1072,10 @@ fn file_link_appends_hash_anchor_when_label_lacks_it() { "[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter([ - "codex-rs/tui/src/markdown_render.rs:74:3".cyan(), - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/markdown_render.rs:74:3", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -1022,9 +1085,10 @@ fn file_link_uses_target_path_for_hash_anchor() { "[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter([ - "codex-rs/tui/src/markdown_render.rs:74:3".cyan(), - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/markdown_render.rs:74:3", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -1034,9 +1098,10 @@ fn file_link_appends_range_when_label_lacks_it() { "[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter([ - "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/markdown_render.rs:74:3-76:9", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -1046,9 +1111,10 @@ fn file_link_uses_target_path_for_range() { "[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter([ - "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/markdown_render.rs:74:3-76:9", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -1058,9 +1124,10 @@ fn file_link_appends_hash_range_when_label_lacks_it() { "[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter([ - "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/markdown_render.rs:74:3-76:9", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } @@ -1077,7 +1144,10 @@ fn multiline_file_link_label_after_styled_prefix_does_not_panic() { " ".into(), "bar".into(), " (".into(), - "codex-rs/tui/src/markdown_render.rs:74:3".cyan(), + Span::styled( + "codex-rs/tui/src/markdown_render.rs:74:3", + MarkdownStyles::default().code, + ), ")".into(), ])); assert_eq!(text, expected); @@ -1089,9 +1159,10 @@ fn file_link_uses_target_path_for_hash_range() { "[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)", Path::new("/Users/example/code/codex"), ); - let expected = Text::from(Line::from_iter([ - "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), - ])); + let expected = Text::from(Line::from_iter([Span::styled( + "codex-rs/tui/src/markdown_render.rs:74:3-76:9", + MarkdownStyles::default().code, + )])); assert_eq!(text, expected); } diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index d9eaaf6491be..59ade8c1a8e7 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -16,7 +16,8 @@ //! config is resolved) to persist the user preference and seed the `THEME` //! lock. After that, [`set_syntax_theme`] and [`current_syntax_theme`] can //! swap/snapshot the theme for live preview. All highlighting functions read -//! the theme via `theme_lock()`. +//! the theme via `theme_lock()`. Unit tests isolate the active theme and its +//! revision per thread so parallel tests cannot change each other’s rendering. //! //! **Guardrails:** inputs exceeding 512 KB or 10 000 lines, or containing an //! individual line longer than 4 KiB, are rejected early (returns `None`) to @@ -32,7 +33,9 @@ use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; use std::sync::RwLock; +#[cfg(not(test))] use std::sync::atomic::AtomicU64; +#[cfg(not(test))] use std::sync::atomic::Ordering; use syntect::easy::HighlightLines; use syntect::highlighting::Color as SyntectColor; @@ -55,11 +58,19 @@ pub(crate) use streaming::StreamingCodeHighlighter; // -- Global singletons ------------------------------------------------------- static SYNTAX_SET: OnceLock = OnceLock::new(); +#[cfg(not(test))] static THEME: OnceLock> = OnceLock::new(); +#[cfg(not(test))] static THEME_REVISION: AtomicU64 = AtomicU64::new(0); static THEME_OVERRIDE: OnceLock> = OnceLock::new(); static CODEX_HOME: OnceLock> = OnceLock::new(); +#[cfg(test)] +thread_local! { + static TEST_THEME: std::cell::OnceCell>> = const { std::cell::OnceCell::new() }; + static TEST_THEME_REVISION: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // Syntect/bat encode ANSI palette semantics in alpha: // `a=0` => indexed ANSI palette via RGB payload, `a=1` => terminal default. const ANSI_ALPHA_INDEX: u8 = 0x00; @@ -95,7 +106,11 @@ pub(crate) fn set_theme_override( let warning = validate_theme_name(name.as_deref(), codex_home.as_deref()); let override_set_ok = THEME_OVERRIDE.set(name.clone()).is_ok(); let codex_home_set_ok = CODEX_HOME.set(codex_home.clone()).is_ok(); - if THEME.get().is_some() { + #[cfg(not(test))] + let initialized = THEME.get().is_some(); + #[cfg(test)] + let initialized = TEST_THEME.with(|theme| theme.get().is_some()); + if initialized { set_syntax_theme(resolve_theme_with_override( name.as_deref(), codex_home.as_deref(), @@ -243,28 +258,47 @@ fn build_default_theme() -> Theme { resolve_theme_with_override(name, codex_home) } -fn theme_lock() -> &'static RwLock { - THEME.get_or_init(|| RwLock::new(build_default_theme())) +fn theme_lock() -> impl std::ops::Deref> { + #[cfg(not(test))] + { + THEME.get_or_init(|| RwLock::new(build_default_theme())) + } + #[cfg(test)] + TEST_THEME.with(|theme| { + theme + .get_or_init(|| std::sync::Arc::new(RwLock::new(build_default_theme()))) + .clone() + }) } /// Swap the active syntax theme at runtime and invalidate rendered-content caches. pub(crate) fn set_syntax_theme(theme: Theme) { - let mut guard = match theme_lock().write() { + let active_theme = theme_lock(); + let mut guard = match active_theme.write() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; *guard = theme; + #[cfg(not(test))] THEME_REVISION.fetch_add(1, Ordering::Release); + #[cfg(test)] + TEST_THEME_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1))); } /// Return the revision of the active syntax theme for rendered-content caches. pub(crate) fn syntax_theme_revision() -> u64 { - THEME_REVISION.load(Ordering::Acquire) + #[cfg(not(test))] + { + THEME_REVISION.load(Ordering::Acquire) + } + #[cfg(test)] + TEST_THEME_REVISION.with(std::cell::Cell::get) } /// Clone the current syntax theme (e.g. to save for cancel-restore). pub(crate) fn current_syntax_theme() -> Theme { - match theme_lock().read() { + let active_theme = theme_lock(); + match active_theme.read() { Ok(theme) => theme.clone(), Err(poisoned) => poisoned.into_inner().clone(), } @@ -666,7 +700,8 @@ fn highlighted_line_spans(ranges: Vec<(SyntectStyle, &str)>) -> Vec Option>>> { - let theme_guard = match theme_lock().read() { + let active_theme = theme_lock(); + let theme_guard = match active_theme.read() { Ok(theme_guard) => theme_guard, Err(poisoned) => poisoned.into_inner(), }; @@ -732,6 +767,15 @@ mod tests { use syntect::highlighting::ThemeItem; use syntect::highlighting::ThemeSettings; + #[test] + fn active_theme_and_revision_are_isolated_between_test_threads() { + let original = (current_syntax_theme(), syntax_theme_revision()); + std::thread::spawn(|| set_syntax_theme(Theme::default())) + .join() + .unwrap(); + assert_eq!((current_syntax_theme(), syntax_theme_revision()), original); + } + fn write_minimal_tmtheme(path: &Path) { // Minimal valid .tmTheme plist (enough for syntect to parse). std::fs::write( diff --git a/codex-rs/tui/src/render/highlight_streaming.rs b/codex-rs/tui/src/render/highlight_streaming.rs index 3eb4de85b8f9..1dc5e1f71a11 100644 --- a/codex-rs/tui/src/render/highlight_streaming.rs +++ b/codex-rs/tui/src/render/highlight_streaming.rs @@ -34,7 +34,8 @@ impl StreamingCodeHighlighter { /// Unknown languages and oversized input retain a permanent plain-text fallback. A changed /// theme or failed replay returns `None`, requiring the caller to render the whole fence again. pub(crate) fn new(code: &str, lang: &str, theme_revision: u64) -> Option { - let theme = match theme_lock().read() { + let active_theme = theme_lock(); + let theme = match active_theme.read() { Ok(theme) => theme, Err(poisoned) => poisoned.into_inner(), }; @@ -90,7 +91,8 @@ impl StreamingCodeHighlighter { { return None; } - let theme = match theme_lock().read() { + let active_theme = theme_lock(); + let theme = match active_theme.read() { Ok(theme) => theme, Err(poisoned) => poisoned.into_inner(), }; diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__inline_code_and_file_paths_follow_syntax_theme.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__inline_code_and_file_paths_follow_syntax_theme.snap new file mode 100644 index 000000000000..e5f6ff5aa12f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__inline_code_and_file_paths_follow_syntax_theme.snap @@ -0,0 +1,66 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: rendered +--- +[ + ( + "catppuccin-mocha", + [ + Line::from_iter([ + Span::from("Use "), + Span::from("src/main.rs").fg(Color::Rgb(166, 227, 161)), + Span::from(" and "), + Span::from("./").fg(Color::Rgb(166, 227, 161)), + ]), + Line::from_iter([ + Span::from("src/lib.rs").fg(Color::Rgb(166, 227, 161)), + Span::from("."), + ]), + ], + ), + ( + "catppuccin-latte", + [ + Line::from_iter([ + Span::from("Use "), + Span::from("src/main.rs").fg(Color::Rgb(64, 160, 43)), + Span::from(" and "), + Span::from("./").fg(Color::Rgb(64, 160, 43)), + ]), + Line::from_iter([ + Span::from("src/lib.rs").fg(Color::Rgb(64, 160, 43)), + Span::from("."), + ]), + ], + ), + ( + "ansi", + [ + Line::from_iter([ + Span::from("Use "), + Span::from("src/main.rs").green(), + Span::from(" and "), + Span::from("./").green(), + ]), + Line::from_iter([ + Span::from("src/lib.rs").green(), + Span::from("."), + ]), + ], + ), + ( + "no-markup-color", + [ + Line::from_iter([ + Span::from("Use "), + Span::from("src/main.rs").cyan(), + Span::from(" and "), + Span::from("./").cyan(), + ]), + Line::from_iter([ + Span::from("src/lib.rs").cyan(), + Span::from("."), + ]), + ], + ), +] diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__web_link_labels_have_a_visible_underline_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__web_link_labels_have_a_visible_underline_snapshot.snap index 655ab46f7803..fa0281ad316c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__web_link_labels_have_a_visible_underline_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__web_link_labels_have_a_visible_underline_snapshot.snap @@ -2,4 +2,4 @@ source: tui/src/markdown_render_tests.rs expression: text --- -Text::from(Line::from_iter([Span::from("plain "), Span::from("plain").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")"), Span::from(" "), Span::from("code").cyan(), Span::from(" "), Span::from("code").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")")])) +Text::from(Line::from_iter([Span::from("plain "), Span::from("plain").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")"), Span::from(" "), Span::from("code").fg(Color::Rgb(166, 227, 161)), Span::from(" "), Span::from("code").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")")])) diff --git a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_file_citations.snap b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_file_citations.snap index 46f1f00bfb53..aed6862c2ae5 100644 --- a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_file_citations.snap +++ b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_file_citations.snap @@ -18,7 +18,7 @@ expression: rendered_cases HyperlinkLine { line: Line::from_iter([ Span::from("- "), - Span::from("Quarterly Report.xlsx").cyan(), + Span::from("Quarterly Report.xlsx").fg(Color::Rgb(166, 227, 161)), ]), hyperlinks: [], }, @@ -46,7 +46,7 @@ expression: rendered_cases HyperlinkLine { line: Line::from_iter([ Span::from("- "), - Span::from("Résumé *final* ✨.xlsx").cyan(), + Span::from("Résumé *final* ✨.xlsx").fg(Color::Rgb(166, 227, 161)), ]), hyperlinks: [], }, From 36b84c81ec0184db024cef38f1d6b55ecf276ef0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 01:10:00 +0000 Subject: [PATCH 008/373] Suppress warnings when skill descriptions are shortened (#46070) ## What changed Stop emitting a warning when skill descriptions are shortened to fit the skills context budget. Continue warning when skills are omitted from the model-visible list. Update the app-server warning test to set `skills.max_context_tokens` to `1_000` and expect the omitted-skills warning. Remove assertions and tests for the description-shortening warning. GitOrigin-RevId: feac10b3d159d73648a786120d477b7fe4e082d7 --- .../tests/suite/v2/executor_skills.rs | 26 ++++---- codex-rs/core/tests/suite/skills_extension.rs | 9 +-- codex-rs/ext/skills/src/render.rs | 6 +- codex-rs/ext/skills/src/render_tests.rs | 59 ------------------- 4 files changed, 17 insertions(+), 83 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/executor_skills.rs b/codex-rs/app-server/tests/suite/v2/executor_skills.rs index d49c683cb4e5..4e5ce64ba683 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_skills.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_skills.rs @@ -384,12 +384,20 @@ stream_max_retries = 0 let request_id = app_server .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), - config: matches!( - scenario, + config: match scenario { + ExecutorSkillScenario::VisibleWithBudgetWarning => Some(HashMap::from([( + "skills.max_context_tokens".to_string(), + json!(1_000), + )])), ExecutorSkillScenario::RestrictedPermittedReference - | ExecutorSkillScenario::RestrictedDeniedReference - ) - .then(|| HashMap::from([("tool_output_token_limit".to_string(), json!(250))])), + | ExecutorSkillScenario::RestrictedDeniedReference => Some(HashMap::from([( + "tool_output_token_limit".to_string(), + json!(250), + )])), + ExecutorSkillScenario::ExplicitOnly | ExecutorSkillScenario::RestrictedVisible => { + None + } + }, selected_capability_roots: Some(vec![SelectedCapabilityRoot { id: "demo-plugin@1".to_string(), location: CapabilityRootLocation::Environment { @@ -444,12 +452,8 @@ stream_max_retries = 0 .await?; } if scenario == ExecutorSkillScenario::VisibleWithBudgetWarning { - let is_skills_budget_warning = |message: &str| { - message.starts_with("Exceeded skills context budget.") - || message.starts_with( - "Skill descriptions were shortened to fit the skills context budget.", - ) - }; + let is_skills_budget_warning = + |message: &str| message.starts_with("Exceeded skills context budget."); let warning = timeout(READ_TIMEOUT, async { loop { let warning: WarningNotification = app_server.read_notification("warning").await?; diff --git a/codex-rs/core/tests/suite/skills_extension.rs b/codex-rs/core/tests/suite/skills_extension.rs index d7b2a7973a6b..204afccffd7c 100644 --- a/codex-rs/core/tests/suite/skills_extension.rs +++ b/codex-rs/core/tests/suite/skills_extension.rs @@ -3754,9 +3754,7 @@ async fn production_turn_fairly_shortens_extension_catalog_descriptions() -> Res .collect(), warnings: Vec::new(), }; - let (event_tx, event_rx) = std::sync::mpsc::channel(); - let mut extensions = - ExtensionRegistryBuilder::::with_event_sink(Arc::new(ChannelEventSink(event_tx))); + let mut extensions = ExtensionRegistryBuilder::::new(); install_with_providers( &mut extensions, SkillProviders::new().with_provider(SkillProviderSource::new( @@ -3812,11 +3810,6 @@ async fn production_turn_fairly_shortens_extension_catalog_descriptions() -> Res .all(|length| *length > 0 && *length < 1_024) ); assert!(!catalog_text.contains("additional skills omitted")); - let warning = event_rx.try_recv()?.into_warning(); - assert_eq!( - warning.message, - "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest." - ); Ok(()) } diff --git a/codex-rs/ext/skills/src/render.rs b/codex-rs/ext/skills/src/render.rs index 7deb8446de1d..ba7a4b7e7108 100644 --- a/codex-rs/ext/skills/src/render.rs +++ b/codex-rs/ext/skills/src/render.rs @@ -20,9 +20,7 @@ const MAX_SKILL_PROMPT_BYTES: usize = 8_000; const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2; const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024; const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "..."; -const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100; const APPROX_BYTES_PER_TOKEN: usize = 4; -const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."; const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str = "Exceeded skills context budget. All skill descriptions were removed and"; pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256; @@ -108,9 +106,7 @@ impl SkillRenderReport { )); } - (self.average_truncated_description_chars() - > SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS) - .then(|| SKILL_DESCRIPTION_TRUNCATED_WARNING.to_string()) + None } pub(crate) fn average_truncated_description_chars(&self) -> usize { diff --git a/codex-rs/ext/skills/src/render_tests.rs b/codex-rs/ext/skills/src/render_tests.rs index 06e40f4baf92..ed4c72b0b864 100644 --- a/codex-rs/ext/skills/src/render_tests.rs +++ b/codex-rs/ext/skills/src/render_tests.rs @@ -1085,62 +1085,3 @@ fn catalog_preserves_report_when_no_fragment_fits_budget() { .is_none() ); } - -#[test] -fn substantial_description_shortening_emits_warning() { - let catalog = SkillCatalog { - entries: vec![ - entry( - "long-skill", - &"a".repeat(250), - /*short_description*/ None, - ), - entry("empty-skill", "", /*short_description*/ None), - ], - warnings: Vec::new(), - }; - let skill_lines = catalog - .entries - .iter() - .map(|entry| SkillLine::new(entry, SkillCatalogRenderPolicy::ExtensionCompatible)) - .collect::>(); - let minimum_cost = skill_lines.iter().fold(0usize, |used, line| { - used.saturating_add(line.minimum_cost(SkillMetadataBudget::Characters(usize::MAX))) - }); - let render = render_available_skills( - &catalog, - SkillCatalogRenderPolicy::ExtensionCompatible, - SkillMetadataBudget::Characters(minimum_cost + 49), - /*include_skills_usage_instructions*/ false, - ) - .expect("catalog should render"); - - assert_eq!( - render.report.warning_message(), - Some( - "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest." - .to_string() - ) - ); -} - -#[test] -fn substantial_description_shortening_warning_starts_above_threshold() { - let report_at_threshold = SkillRenderReport { - total_count: 2, - included_count: 2, - omitted_count: 0, - truncated_description_chars: 200, - truncated_description_count: 2, - }; - assert_eq!(report_at_threshold.warning_message(), None); - - let report_above_threshold = SkillRenderReport { - truncated_description_chars: 201, - ..report_at_threshold - }; - assert_eq!( - report_above_threshold.warning_message(), - Some(SKILL_DESCRIPTION_TRUNCATED_WARNING.to_string()) - ); -} From ce03f22af6d37572c45ed2a6a79862ddef140944 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 01:11:39 +0000 Subject: [PATCH 009/373] Add a configurable F8 shortcut for voice conversations (#46071) ## What changed - Bind `F8` to start or stop a voice conversation through the existing voice toggle handler, preserving its start guards and the composer draft. - Expose `tui.keymap.chat.toggle_voice` in the configuration schema and keymap picker, with support for remapping, unbinding, and key chords. - Let the default yield to existing `F8` bindings and overlapping chord prefixes, and validate explicit bindings against shortcut conflicts and reserved text input. - Handle the shortcut only on key presses when no modal or popup is active. ## Testing Add tests for default and custom bindings, conflicts, chord dispatch, live remapping and unbinding, draft preservation, start guards, and popup and key-event handling. Add a snapshot for the voice shortcut editor. GitOrigin-RevId: e5e373e8eefa89bec4d5cbdca801616e312134b5 --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 ++ .../app/tests/session_lifecycle_requests.rs | 31 +++--- codex-rs/tui/src/chatwidget/interaction.rs | 8 ++ .../realtime/recording_controls_tests.rs | 66 +++++++++++++ codex-rs/tui/src/keymap.rs | 39 ++++++-- codex-rs/tui/src/keymap/bindings.rs | 1 + codex-rs/tui/src/keymap/voice_tests.rs | 99 +++++++++++++++++++ codex-rs/tui/src/keymap_setup.rs | 16 +++ codex-rs/tui/src/keymap_setup/actions.rs | 2 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 2 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- ...setup__tests__keymap_question_actions.snap | 2 +- ...map_setup__tests__keymap_voice_toggle.snap | 22 +++++ 18 files changed, 279 insertions(+), 33 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index a1a6550b18b7..67ba13f86773 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -123,6 +123,8 @@ pub struct TuiGlobalKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiChatKeymap { + /// Start or stop a voice conversation. + pub toggle_voice: Option, /// Toggle the microphone in an active voice conversation. pub toggle_voice_mute: Option, /// Interrupt the active turn. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7aa4f9421969..e53a95e2d51a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4139,6 +4139,7 @@ "previous_permission_mode": null, "prompt_stack_back": null, "skip_question": null, + "toggle_voice": null, "toggle_voice_mute": null }, "composer": { @@ -4649,6 +4650,14 @@ ], "description": "Skip the focused question." }, + "toggle_voice": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Start or stop a voice conversation." + }, "toggle_voice_mute": { "allOf": [ { @@ -5008,6 +5017,7 @@ "previous_permission_mode": null, "prompt_stack_back": null, "skip_question": null, + "toggle_voice": null, "toggle_voice_mute": null } }, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index b7ddeb167d1c..be68e78b0b13 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2743,14 +2743,13 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { .await?; app_server.remember_thread_history_mode(paginated_thread_id, ThreadHistoryMode::Legacy); - let resumed = app_server - .resume_thread( - &app.local_settings, - app.config.clone(), - paginated_thread_id, - crate::app_server_session::ResumeModelSettings::RestoreFromThread, - ) - .await?; + let resumed = Box::pin(app_server.resume_thread( + &app.local_settings, + app.config.clone(), + paginated_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + )) + .await?; assert_eq!(resumed.session.thread_id, paginated_thread_id); assert!(recorded_params(&requests, "thread/read").is_empty()); let resume_requests = recorded_params(&requests, "thread/resume"); @@ -2764,18 +2763,16 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { ) .await?; assert!(!cells.is_empty()); - app_server - .fork_thread(&app.local_settings, app.config.clone(), paginated_thread_id) + Box::pin(app_server.fork_thread(&app.local_settings, app.config.clone(), paginated_thread_id)) .await?; let mut side_config = app.config.clone(); side_config.ephemeral = true; - app_server - .fork_side_thread( - &crate::local_settings::LocalSettings::from(&side_config), - side_config, - paginated_thread_id, - ) - .await?; + Box::pin(app_server.fork_side_thread( + &crate::local_settings::LocalSettings::from(&side_config), + side_config, + paginated_thread_id, + )) + .await?; let paginated_reads = recorded_params(&requests, "thread/read"); assert!(!paginated_reads.is_empty()); diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index de29f1da10e2..c767c8460a74 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -78,6 +78,14 @@ impl ChatWidget { return; } + if key_event.kind == KeyEventKind::Press + && self.chat_keymap.toggle_voice.is_pressed(key_event) + && self.bottom_pane.no_modal_or_popup_active() + { + self.toggle_realtime_conversation(); + return; + } + if self.handle_realtime_microphone_shortcut(key_event) { return; } diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs index b288ae102947..d92433860285 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs @@ -717,3 +717,69 @@ async fn compact_voice_meters_keep_real_speaker_history_when_the_microphone_is_m chat.update_realtime_footer(); assert!(!render_bottom_popup(&chat, /*width*/ 45).contains("codex")); } + +#[tokio::test] +async fn voice_toggle_shortcut_uses_the_slash_command_start_guard_and_preserves_draft() { + let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; + chat.set_side_conversation_active(/*active*/ true); + chat.bottom_pane + .set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + + let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else { + panic!("voice should report that side conversations are unsupported"); + }; + assert!( + cell.display_lines(/*width*/ 80) + .iter() + .any(|line| line.to_string().contains("side conversations")) + ); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("draft")); + assert!(!chat.realtime_conversation_is_running()); + assert!(ops.try_recv().is_err()); +} + +#[tokio::test] +async fn voice_toggle_shortcut_stops_only_on_press_outside_popups() { + let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await; + let thread_id = activate_voice(&mut chat); + let shortcut = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE); + for kind in [KeyEventKind::Repeat, KeyEventKind::Release] { + chat.handle_key_event(KeyEvent { kind, ..shortcut }); + assert!(chat.realtime_conversation_is_running()); + } + chat.bottom_pane + .set_composer_text("/".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(shortcut); + assert!(chat.realtime_conversation_is_running()); + assert!(ops.try_recv().is_err()); + chat.bottom_pane + .set_composer_text(String::new(), Vec::new(), Vec::new()); + + chat.handle_key_event(shortcut); + + assert!(!chat.realtime_conversation_is_running()); + assert!( + matches!(ops.try_recv(), Ok(AppCommand::RealtimeConversationStop { thread_id: stopped }) if stopped == thread_id) + ); +} + +#[tokio::test] +async fn voice_toggle_shortcut_respects_live_remapping_and_unbinding() { + let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await; + for (binding, stops) in [("'f9'", true), ("[]", false)] { + let config = toml::from_str::(&format!( + "[chat]\ntoggle_voice = {binding}" + )) + .unwrap(); + let runtime = crate::keymap::RuntimeKeymap::from_config(&config).unwrap(); + chat.apply_keymap_update(config, &runtime); + activate_voice(&mut chat); + chat.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert!(chat.realtime_conversation_is_running()); + chat.handle_key_event(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)); + assert_eq!(chat.realtime_conversation_is_running(), !stops); + assert_eq!(ops.try_recv().is_ok(), stops); + } +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 40404e8339b1..15d3b32b03c9 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -115,6 +115,8 @@ pub(crate) struct AppKeymap { /// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { + /// Start or stop a voice conversation. + pub(crate) toggle_voice: Vec, /// Toggle capture in the active voice session. pub(crate) toggle_voice_mute: Vec, chord_hints: Arc, @@ -627,6 +629,13 @@ impl RuntimeKeymap { || configured_context_alias_is_used(&keymap.list, alias) || configured_context_alias_is_used(&keymap.approval, alias) }); + let voice_toggle_default_is_shadowed = keymap.chat.toggle_voice.is_none() + && (configured_main_surface_alias_is_used(keymap, "f8") + || configured_context_alias_is_used(&keymap.vim_search, "f8") + || chords.bindings.iter().any(|binding| { + binding.action.context.overlaps(KeymapContext::Chat) + && binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(8)).parts() + })); // Preserve existing Ctrl+X shortcuts and chord prefixes when adding this default. let voice_mute_default_is_shadowed = keymap.chat.toggle_voice_mute.is_none() && (configured_main_surface_alias_is_used(keymap, "ctrl-x") @@ -689,6 +698,15 @@ impl RuntimeKeymap { }; let mut chat = ChatKeymap { + toggle_voice: if voice_toggle_default_is_shadowed { + Vec::new() + } else { + resolve_bindings( + keymap.chat.toggle_voice.as_ref(), + &defaults.chat.toggle_voice, + "tui.keymap.chat.toggle_voice", + )? + }, toggle_voice_mute: if voice_mute_default_is_shadowed { Vec::new() } else { @@ -1570,6 +1588,7 @@ impl RuntimeKeymap { }, chords: Arc::default(), chat: ChatKeymap { + toggle_voice: default_bindings![plain(KeyCode::F(8))], toggle_voice_mute: default_bindings![ctrl(KeyCode::Char('x'))], chord_hints: Arc::default(), interrupt_turn: default_bindings![plain(KeyCode::Esc)], @@ -1864,6 +1883,7 @@ impl RuntimeKeymap { /// backtracking, intentionally stay outside this configurable keymap. fn validate_conflicts(&self) -> Result<(), String> { for (action, bindings) in [ + ("toggle_voice", &self.chat.toggle_voice), ( "previous_permission_mode", &self.chat.previous_permission_mode, @@ -1883,14 +1903,15 @@ impl RuntimeKeymap { } } #[cfg(unix)] - if self - .app - .open_agents - .contains(&key_hint::ctrl(KeyCode::Char('z'))) - { - return Err( - "tui.keymap.global.open_agents: ctrl-z is reserved for suspend".to_string(), - ); + for (action, bindings) in [ + ("global.open_agents", &self.app.open_agents), + ("chat.toggle_voice", &self.chat.toggle_voice), + ] { + if bindings.contains(&key_hint::ctrl(KeyCode::Char('z'))) { + return Err(format!( + "tui.keymap.{action}: ctrl-z is reserved for suspend" + )); + } } if self.app.open_agents.iter().any(|binding| { matches!(binding.parts(), (KeyCode::Char(_), modifiers) @@ -1923,6 +1944,7 @@ impl RuntimeKeymap { ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ("toggle_side_conversation", side_toggle_bindings.as_slice()), + ("chat.toggle_voice", self.chat.toggle_voice.as_slice()), ( "chat.toggle_voice_mute", self.chat.toggle_voice_mute.as_slice(), @@ -2077,6 +2099,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("chat.toggle_voice", self.chat.toggle_voice.as_slice()), ( "chat.toggle_voice_mute", self.chat.toggle_voice_mute.as_slice(), diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 44c6ea7477f6..99a6d978232d 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -260,6 +260,7 @@ define_runtime_action_bindings! { toggle_side_conversation, ], "chat" => Chat, chat, chat [ + toggle_voice, interrupt_turn, decrease_reasoning_effort, increase_reasoning_effort, diff --git a/codex-rs/tui/src/keymap/voice_tests.rs b/codex-rs/tui/src/keymap/voice_tests.rs index 7e66099edc84..7e6e269a5d85 100644 --- a/codex-rs/tui/src/keymap/voice_tests.rs +++ b/codex-rs/tui/src/keymap/voice_tests.rs @@ -83,3 +83,102 @@ fn voice_mute_chord_dispatches_only_in_its_active_context() { }; assert!(runtime.chat.toggle_voice_mute.is_pressed(dispatch)); } + +#[test] +fn voice_toggle_resolves_custom_bindings_unbinding_and_visible_hints() { + for (config, expected) in [ + ("", Some("f8")), + ("[chat]\ntoggle_voice = 'f9'", Some("f9")), + ("[chat]\ntoggle_voice = []", None), + ("[chat]\ntoggle_voice = 'f8 v'", Some("f8 v")), + ] { + let config = toml::from_str::(config).unwrap(); + let runtime = RuntimeKeymap::from_config(&config).unwrap(); + assert_eq!( + runtime + .primary_hint(KeymapContext::Chat, "toggle_voice") + .map(ShortcutHint::display_label), + expected.map(str::to_string), + ); + } +} + +#[test] +fn voice_toggle_default_yields_to_existing_shortcuts_and_chord_prefixes() { + for config in [ + "[editor]\nkill_line_end = 'f8'", + "[vim_search]\nnext = 'f8'", + "[chat]\ntoggle_voice_mute = 'f8'", + "[global]\nopen_transcript = 'f8 t'", + ] { + let config = toml::from_str::(config).unwrap(); + let runtime = RuntimeKeymap::from_config(&config).expect("existing binding stays valid"); + assert_eq!( + runtime.primary_hint(KeymapContext::Chat, "toggle_voice"), + None + ); + } + for (binding, conflict) in [ + ("ctrl-k", "kill_line_end"), + ("ctrl-c", "fixed.interrupt_or_quit"), + ("ctrl-t", "open_transcript"), + #[cfg(unix)] + ("ctrl-z", "ctrl-z is reserved for suspend"), + ] { + let config = + toml::from_str::(&format!("[chat]\ntoggle_voice = '{binding}'")).unwrap(); + assert!( + RuntimeKeymap::from_config(&config) + .unwrap_err() + .contains(conflict) + ); + } +} + +#[test] +fn voice_toggle_chord_dispatches_in_chat_without_an_active_voice_session() { + let config = toml::from_str::("[chat]\ntoggle_voice = 'f8 v'").unwrap(); + let runtime = RuntimeKeymap::from_config(&config).unwrap(); + let mut matcher = KeyChordMatcher::default(); + let now = tokio::time::Instant::now(); + let prefix = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE); + assert_eq!( + matcher.advance( + prefix, + &runtime.chords, + KeymapContextSet::new(KeymapContext::Pager), + now + ), + KeyChordMatch::PassThrough + ); + let contexts = KeymapContextSet::new(KeymapContext::Chat); + assert!(matches!( + matcher.advance(prefix, &runtime.chords, contexts, now), + KeyChordMatch::Pending(_) + )); + let KeyChordMatch::Completed(dispatch) = matcher.advance( + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), + &runtime.chords, + contexts, + now, + ) else { + panic!("voice toggle chord completes") + }; + assert!(runtime.chat.toggle_voice.is_pressed(dispatch)); +} + +#[test] +fn voice_toggle_reserves_text_input() { + for binding in ["v", "shift-v", "2", "space"] + .into_iter() + .chain(cfg!(windows).then_some("ctrl-alt-q")) + { + let config = + toml::from_str::(&format!("[chat]\ntoggle_voice = '{binding}'")).unwrap(); + assert!( + RuntimeKeymap::from_config(&config) + .expect_err("voice shortcuts must not intercept typing") + .contains("printable keys are reserved for text input") + ); + } +} diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index a2b5edc3bb37..2610da3eb1f3 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1172,6 +1172,22 @@ mod tests { assert_snapshot!("keymap_picker_redo", redo_row); } + #[test] + fn picker_voice_toggle_snapshot() { + assert_snapshot!( + "keymap_voice_toggle", + render_picker( + build_keymap_action_menu_params( + "chat".into(), + "toggle_voice".into(), + &RuntimeKeymap::defaults(), + &TuiKeymap::default(), + ), + /*width*/ 80, + ) + ); + } + #[test] fn picker_question_actions_snapshot() { let runtime = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 0c809ea1ffa9..04a8590ed7fa 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -101,6 +101,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("chat", "Chat", "edit_queued_message", "Move up through questions, then edit the last queued message."), action("chat", "Chat", "prompt_stack_back", "Move back through questions toward the composer."), action("chat", "Chat", "skip_question", "Skip the focused question."), + action("chat", "Chat", "toggle_voice", "Start or stop a voice conversation."), action("chat", "Chat", "toggle_voice_mute", "Mute or unmute the voice microphone."), action("composer", "Composer", "submit", "Submit the current composer draft."), action("composer", "Composer", "queue", "Queue the draft while a task is running."), @@ -282,6 +283,7 @@ pub(super) fn binding_slot<'a>( ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), ("chat", "prompt_stack_back") => Some(&mut keymap.chat.prompt_stack_back), ("chat", "skip_question") => Some(&mut keymap.chat.skip_question), + ("chat", "toggle_voice") => Some(&mut keymap.chat.toggle_voice), ("chat", "toggle_voice_mute") => Some(&mut keymap.chat.toggle_voice_mute), ("composer", "submit") => Some(&mut keymap.composer.submit), ("composer", "queue") => Some(&mut keymap.composer.queue), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index edc15b790623..31d3b5cf9a0f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 146 actions, 1 customized, 5 unbound. + 147 actions, 1 customized, 5 unbound. [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index ccad420a1931..7eb43c0ffd68 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 147 actions, 0 customized, 6 unbound. + 148 actions, 0 customized, 6 unbound. [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 1161a41966ce..3f190d040527 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (146 selectable) +tab: All (147 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) -tab: App (17 selectable) +tab: App (18 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index 85bbb42f4ced..b8b07e81acbc 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 146 actions, 1 customized, 6 unbound. + 147 actions, 1 customized, 6 unbound. [All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 20cad9e8110c..b5223dd45248 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 146 actions, 0 customized, 5 unbound. + 147 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index fbfec35d5e17..dd888c732d98 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 146 actions, 0 customized, 5 unbound. + 147 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap index 5041ccfa9526..259f8fddc914 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 146 actions, 0 customized, 5 unbound. + 147 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap new file mode 100644 index 000000000000..2e150df2cf71 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(build_keymap_action_menu_params(\"chat\".into(),\n\"toggle_voice\".into(), &RuntimeKeymap::defaults(), &TuiKeymap::default(),),\n80,)" +--- + + Edit Shortcut + Toggle Voice · Chat + Current f8 · Default keymap + Config `tui.keymap.chat.toggle_voice` + Start or stop a voice conversation. + +› 1. Replace binding Capture a replacement key for `f8`. + 2. Add alternate binding Keep `f8` and add another key. + 3. Replace with key chord Replace `f8` with a two-stroke key + chord. + 4. Add alternate key chord Keep `f8` and add a two-stroke key + chord. + – Remove custom binding (disabled) No custom root override to remove. + 5. Back to shortcuts Return to the shortcut list. + + Changes write the root `tui.keymap.*` override. + enter select · esc back From b974893c90048d147207ae9004489c02f3116426 Mon Sep 17 00:00:00 2001 From: Krish Chainani Date: Thu, 17 Sep 2026 01:14:15 +0000 Subject: [PATCH 010/373] Account for file images in context budgets and Guardian reviews (#46072) ## Why File images contributed zero to history and compaction token estimates and were dropped from Guardian context. This undercounted context usage and omitted image evidence from reviews. ## What changed - Estimate file images using the fixed resized-image cost, or 10,000 patch tokens for `original` detail because file references do not expose dimensions. - Charge file images against remote compaction and Guardian request budgets. - Preserve file images from user messages and tool outputs through Guardian selection and delivery, and deduplicate review images by file ID. - Apply transcript image count and byte limits to both reference types, counting file ID bytes rather than referenced file contents. ## Testing Add coverage for file-image estimates, selection limits, delivery, and compaction retention. Extend Guardian tests to verify file-image evidence reaches reviews and uploaded original-detail images trigger reviewer-history compaction. GitOrigin-RevId: ee024204dcbe16e277fea9347c6a4d7fc120e18d --- .../compact_remote_v2_image_budget_tests.rs | 41 ++++--- codex-rs/core/src/compact_remote_v2_images.rs | 16 +-- codex-rs/core/src/context_manager/history.rs | 43 ++++--- .../core/src/context_manager/history_tests.rs | 45 ++++++- codex-rs/core/src/context_manager/mod.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 60 +++++---- codex-rs/core/tests/suite/compact_remote.rs | 65 ++++++++-- .../tests/suite/guardian_context_budget.rs | 60 +++++++-- codex-rs/core/tests/suite/image_rollout.rs | 4 +- .../src/async_scorer/extension_tests.rs | 24 +++- codex-rs/guardian-context/src/budget.rs | 21 ++-- codex-rs/guardian-context/src/budget_tests.rs | 16 ++- codex-rs/guardian-context/src/composition.rs | 16 +-- .../guardian-context/src/composition_tests.rs | 47 +++++-- codex-rs/guardian-context/src/enforcement.rs | 14 +-- .../guardian-context/src/enforcement_tests.rs | 24 +++- codex-rs/guardian-context/src/images.rs | 64 +++++----- codex-rs/guardian-context/src/images_tests.rs | 116 +++++++++++++++--- 18 files changed, 480 insertions(+), 198 deletions(-) diff --git a/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs b/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs index 7c7d94ac8b41..ea37694606b2 100644 --- a/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs +++ b/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs @@ -25,6 +25,15 @@ fn image() -> ContentItem { } } +fn file_image() -> ContentItem { + ContentItem::InputImage { + image: ImageReference::File { + file_id: "file_123".to_string(), + }, + detail: Some(codex_protocol::models::ImageDetail::Original), + } +} + fn text(value: &str) -> ContentItem { ContentItem::InputText { text: value.to_string(), @@ -45,21 +54,23 @@ fn trim(items: Vec, max_tokens: usize) -> Vec { #[test] fn image_only_boundary_is_atomic_and_does_not_backfill_older_messages() { let newest = message(vec![text("new")]); - let items = vec![ - message(vec![text("old")]), - message(vec![image()]), - newest.clone(), - ]; - let image_tokens = images::content_item_token_count(&image()); - for (max_tokens, expected) in [ - ( - image_tokens + 1, - vec![message(vec![image()]), newest.clone()], - ), - (image_tokens, vec![newest.clone()]), - (1, vec![newest]), - ] { - assert_eq!(trim(items.clone(), max_tokens), expected); + for image in [image(), file_image()] { + let items = vec![ + message(vec![text("old")]), + message(vec![image.clone()]), + newest.clone(), + ]; + let image_tokens = images::content_item_token_count(&image); + for (max_tokens, expected) in [ + ( + image_tokens + 1, + vec![message(vec![image.clone()]), newest.clone()], + ), + (image_tokens, vec![newest.clone()]), + (1, vec![newest.clone()]), + ] { + assert_eq!(trim(items.clone(), max_tokens), expected); + } } } diff --git a/codex-rs/core/src/compact_remote_v2_images.rs b/codex-rs/core/src/compact_remote_v2_images.rs index 94cbdc239ebd..6e4914e9a440 100644 --- a/codex-rs/core/src/compact_remote_v2_images.rs +++ b/codex-rs/core/src/compact_remote_v2_images.rs @@ -1,10 +1,9 @@ -use crate::context_manager::estimate_image_bytes; +use crate::context_manager::estimate_image_reference_bytes; use codex_context_fragments::AnnotatedContent; use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; use codex_history::ResponseItemEnvelope; use codex_protocol::models::ContentItem; -use codex_protocol::models::ImageReference; use codex_protocol::models::is_image_close_tag_text; use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_open_tag_text; @@ -18,17 +17,10 @@ pub(super) fn content_item_token_count(item: &ContentItem) -> usize { ContentItem::InputText { text } | ContentItem::OutputText { text } => { approx_token_count(text) } - ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } => usize::try_from(approx_tokens_from_byte_count_i64(estimate_image_bytes( - image_url, *detail, - ))) + ContentItem::InputImage { image, detail } => usize::try_from( + approx_tokens_from_byte_count_i64(estimate_image_reference_bytes(image, *detail)), + ) .unwrap_or(usize::MAX), - ContentItem::InputImage { - image: ImageReference::File { .. }, - .. - } => 0, ContentItem::InputAudio { .. } => 0, } } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 883731f67c20..b7ec2e480201 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -890,6 +890,7 @@ fn estimate_encrypted_function_output_length(encoded_len: usize) -> usize { /// Returns the same coarse, model-visible token estimate used for full history estimates. /// /// Counts content directly, excluding transport IDs, metadata, and outer JSON escaping. +/// Original-detail file images use the maximum patch count. pub(crate) fn estimate_item_token_count(item: &ResponseItem) -> i64 { let model_visible_bytes = estimate_response_item_model_visible_bytes(item); approx_tokens_from_byte_count_i64(model_visible_bytes) @@ -918,7 +919,6 @@ static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock i64 { - // TODO(kc) Account for file-backed image size after its token-cost contract is defined. match item { ResponseItem::Message { content, .. } => content .iter() @@ -926,14 +926,9 @@ fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 { ContentItem::InputText { text } | ContentItem::OutputText { text } => { text_bytes(text) } - ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } => estimate_image_bytes(image_url, *detail), - ContentItem::InputImage { - image: ImageReference::File { .. }, - .. - } => 0, + ContentItem::InputImage { image, detail } => { + estimate_image_reference_bytes(image, *detail) + } ContentItem::InputAudio { audio_url } => estimate_audio_bytes(audio_url), }) .fold(0i64, i64::saturating_add), @@ -1112,8 +1107,8 @@ fn estimate_original_image_bytes(image_url: &str) -> Option { }) } -/// Shared image estimate, excluding message framing. -pub(crate) fn estimate_image_bytes(image_url: &str, detail: Option) -> i64 { +/// Inline image estimate, excluding the data URL prefix and message framing. +fn estimate_image_bytes(image_url: &str, detail: Option) -> i64 { match detail { Some(ImageDetail::Original) => { estimate_original_image_bytes(image_url).unwrap_or(RESIZED_IMAGE_BYTES_ESTIMATE) @@ -1122,6 +1117,21 @@ pub(crate) fn estimate_image_bytes(image_url: &str, detail: Option) } } +/// Image estimate for callers that only have the reference. Original-detail file images use the +/// maximum patch count because their dimensions are not available from the reference. +pub(crate) fn estimate_image_reference_bytes( + image: &ImageReference, + detail: Option, +) -> i64 { + match image { + ImageReference::Inline { image_url } => estimate_image_bytes(image_url, detail), + ImageReference::File { .. } if detail == Some(ImageDetail::Original) => { + i64::try_from(approx_bytes_for_tokens(ORIGINAL_IMAGE_MAX_PATCHES)).unwrap_or(i64::MAX) + } + ImageReference::File { .. } => RESIZED_IMAGE_BYTES_ESTIMATE, + } +} + fn estimate_audio_bytes(audio_url: &str) -> i64 { i64::try_from(approx_bytes_for_tokens(estimate_audio_token_count( audio_url, @@ -1136,14 +1146,9 @@ fn estimate_function_output_bytes(output: &FunctionCallOutputBody) -> i64 { .iter() .map(|part| match part { FunctionCallOutputContentItem::InputText { text } => text_bytes(text), - FunctionCallOutputContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } => estimate_image_bytes(image_url, *detail), - FunctionCallOutputContentItem::InputImage { - image: ImageReference::File { .. }, - .. - } => 0, + FunctionCallOutputContentItem::InputImage { image, detail } => { + estimate_image_reference_bytes(image, *detail) + } FunctionCallOutputContentItem::InputAudio { audio_url } => { estimate_audio_bytes(audio_url) } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index a145ee20fbdc..704b8a56cf80 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -2577,8 +2577,39 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { assert!(estimated > text_only_estimated); } +/// File images use the fixed estimate for normal detail and the maximum patch count for original. #[test] -fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { +fn file_images_use_detail_appropriate_estimates() { + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputImage { + image: ImageReference::File { + file_id: "file_high".to_string(), + }, + detail: Some(ImageDetail::High), + }, + ContentItem::InputImage { + image: ImageReference::File { + file_id: "file_original".to_string(), + }, + detail: Some(ImageDetail::Original), + }, + ], + phase: None, + internal_chat_message_metadata_passthrough: None, + }; + + let estimated = estimate_response_item_model_visible_bytes(&item); + let expected = RESIZED_IMAGE_BYTES_ESTIMATE + .saturating_add(approx_bytes_for_tokens(/*tokens*/ 10_000) as i64); + + assert_eq!(estimated, expected); +} + +#[test] +fn function_call_output_estimates_inline_and_file_images() { let payload = "B".repeat(50_000); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { @@ -2594,14 +2625,22 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { image: ImageReference::Inline { image_url }, detail: Some(DEFAULT_IMAGE_DETAIL), }, + FunctionCallOutputContentItem::InputImage { + image: ImageReference::File { + file_id: "file_original".to_string(), + }, + detail: Some(ImageDetail::Original), + }, ]), internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; let estimated = estimate_response_item_model_visible_bytes(&item); - let expected = - "call-abc".len() as i64 + "Screenshot captured".len() as i64 + RESIZED_IMAGE_BYTES_ESTIMATE; + let expected = "call-abc".len() as i64 + + "Screenshot captured".len() as i64 + + RESIZED_IMAGE_BYTES_ESTIMATE + + approx_bytes_for_tokens(/*tokens*/ 10_000) as i64; assert_eq!(estimated, expected); assert!(estimated < raw_len); diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index af520df6cfe6..8f3a347d2065 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -4,6 +4,6 @@ pub(crate) mod updates; pub(crate) use history::ContextManager; pub(crate) use history::HistoryReplacement; -pub(crate) use history::estimate_image_bytes; +pub(crate) use history::estimate_image_reference_bytes; pub(crate) use history::estimate_item_token_count; pub(crate) use history::is_user_turn_boundary; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7179aa3175eb..d176191972ba 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -508,20 +508,26 @@ async fn run_review_on_session( .any(|(_, cost)| cost.image_count > 0) { let reviewer_history = review_session.session.clone_history().await; - let reviewer_image_urls = reviewer_history - .raw_items() - .flat_map(|item| match item { - ResponseItem::Message { content, .. } => content.as_slice(), - _ => &[], - }) - .filter_map(|item| match item { - ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - .. - } => Some(image_url.as_str()), - _ => None, - }) - .collect::>(); + let mut reviewer_image_urls = HashSet::new(); + let mut reviewer_file_ids = HashSet::new(); + for item in reviewer_history.raw_items() { + let ResponseItem::Message { content, .. } = item else { + continue; + }; + for item in content { + let ContentItem::InputImage { image, .. } = item else { + continue; + }; + match image { + ImageReference::Inline { image_url } => { + reviewer_image_urls.insert(image_url.as_str()); + } + ImageReference::File { file_id } => { + reviewer_file_ids.insert(file_id.as_str()); + } + } + } + } let context_window = model_info.resolved_context_window().map(|supported| { params .spawn_config @@ -542,7 +548,7 @@ async fn run_review_on_session( } else { ImagePreparationMode::DetailBased }; - prompt_items.context.retain_images(|image_url, detail| { + prompt_items.context.retain_images(|image, detail| { *detail = match normalize_output_image_detail(&model_info, *detail) { _ if mode == ImagePreparationMode::UnifiedBudget => { Some(ImageDetail::Original) @@ -550,15 +556,23 @@ async fn run_review_on_session( Some(ImageDetail::Low) => Some(ImageDetail::High), detail => detail, }; - let prepared_image_url = match resize_image(image_url, detail, mode) { - Ok(Some(prepared)) => Cow::Owned(prepared.into_data_url()), - Ok(None) => Cow::Borrowed(image_url), - Err(error) => { - warn!(%error, "failed to prepare guardian review image"); - return false; + match image { + ImageReference::Inline { image_url } => { + let prepared_image_url = match resize_image(image_url, detail, mode) + { + Ok(Some(prepared)) => Cow::Owned(prepared.into_data_url()), + Ok(None) => Cow::Borrowed(image_url), + Err(error) => { + warn!(%error, "failed to prepare guardian review image"); + return false; + } + }; + !reviewer_image_urls.contains(prepared_image_url.as_str()) } - }; - !reviewer_image_urls.contains(prepared_image_url.as_ref()) + ImageReference::File { file_id } => { + !reviewer_file_ids.contains(file_id.as_str()) + } + } }); let prompt: ResponseItem = ResponseInputItem::from(prompt_items.context.clone().into_user_inputs()?) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 82ed5f5092d8..f7dc16e13d31 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -605,11 +605,14 @@ async fn amazon_bedrock_automatic_compaction_uses_v2_responses_endpoint() -> Res Ok(()) } -#[test_case(None; "default_trims_images")] -#[test_case(Some(false); "disabled_preserves_images")] +#[test_case(None, "image_url"; "default_trims_images")] +#[test_case(Some(false), "image_url"; "disabled_preserves_images")] +#[test_case(None, "file_id"; "default_trims_file_images")] +#[test_case(Some(false), "file_id"; "disabled_preserves_file_images")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_v2_charges_retained_images_to_token_budget( image_budget_enabled: Option, + image_field: &str, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -630,6 +633,14 @@ async fn remote_compact_v2_charges_retained_images_to_token_budget( // Each original-detail image costs 10,000 estimated patch tokens. let image_inputs = (1..=8) .map(|number| { + if image_field == "file_id" { + return Ok(UserInput::Image { + image: ImageReference::File { + file_id: format!("file_{number}"), + }, + detail: Some(codex_protocol::models::ImageDetail::Original), + }); + } let image = image::ImageBuffer::from_pixel( /*width*/ 3200, /*height*/ 3200, @@ -666,7 +677,24 @@ async fn remote_compact_v2_charges_retained_images_to_token_budget( .await?; wait_for_turn_complete(codex).await; let initial_request = initial_mock.single_request(); - let prepared_images = initial_request.message_input_image_urls("user"); + let prepared_images = initial_request + .inputs_of_type("message") + .into_iter() + .filter(|item| item["role"] == "user") + .flat_map(|item| { + item["content"] + .as_array() + .expect("message content is an array") + .clone() + }) + .filter(|item| item["type"] == "input_image") + .map(|item| { + item[image_field] + .as_str() + .expect("image input has a string reference") + .to_owned() + }) + .collect::>(); assert_eq!(prepared_images.len(), 7); for cycle in 1..=2 { @@ -717,16 +745,33 @@ async fn remote_compact_v2_charges_retained_images_to_token_budget( }; let mut expected_images = prepared_images[dropped..].to_vec(); if cycle == 2 { - let UserInput::Image { - image: ImageReference::Inline { image_url }, - .. - } = &image_inputs[7] - else { + let UserInput::Image { image, .. } = &image_inputs[7] else { unreachable!() }; - expected_images.push(image_url.clone()); + expected_images.push(match image { + ImageReference::Inline { image_url } => image_url.clone(), + ImageReference::File { file_id } => file_id.clone(), + }); } - assert_eq!(follow_up.message_input_image_urls("user"), expected_images); + let retained_images = follow_up + .inputs_of_type("message") + .into_iter() + .filter(|item| item["role"] == "user") + .flat_map(|item| { + item["content"] + .as_array() + .expect("message content is an array") + .clone() + }) + .filter(|item| item["type"] == "input_image") + .map(|item| { + item[image_field] + .as_str() + .expect("image input has a string reference") + .to_owned() + }) + .collect::>(); + assert_eq!(retained_images, expected_images); assert!( follow_up .message_input_texts("user") diff --git a/codex-rs/core/tests/suite/guardian_context_budget.rs b/codex-rs/core/tests/suite/guardian_context_budget.rs index 29be5e7a5934..b2c658d73002 100644 --- a/codex-rs/core/tests/suite/guardian_context_budget.rs +++ b/codex-rs/core/tests/suite/guardian_context_budget.rs @@ -30,8 +30,11 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::json; +use std::sync::Arc; use test_case::test_case; +use super::image_rollout::RecordingFileAttachmentStore; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(160_000, &[0, 0]; "complete_instructions_fit")] #[test_case(28_000, &[0, 1]; "oversized_history_compacts_then_truncates")] @@ -198,6 +201,7 @@ async fn review_preserves_user_instructions_until_request_budgeting( enum ReviewerResponse { Decision, ToolContinuation, + FileImageContinuation, UncompactableContinuation, CompactionError, NextReview, @@ -206,6 +210,7 @@ enum ReviewerResponse { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(1, ReviewerResponse::Decision; "required_context_fails_closed")] #[test_case(4_500, ReviewerResponse::ToolContinuation; "oversized_tool_continuation_compacts")] +#[test_case(4_500, ReviewerResponse::FileImageContinuation; "uploaded_original_image_history_compacts")] #[test_case(4_500, ReviewerResponse::UncompactableContinuation; "ineffective_compaction_fails_closed")] #[test_case(4_500, ReviewerResponse::CompactionError; "compaction_service_error_does_not_request_user_approval")] #[test_case(6_000, ReviewerResponse::NextReview; "incoming_review_compacts_existing_history")] @@ -231,6 +236,10 @@ async fn review_respects_complete_context_budget( ) .with_model_info_override("gpt-5.6-luna", move |model| { model.context_window = Some(window); + if matches!(reviewer_response, ReviewerResponse::FileImageContinuation) { + model.supports_image_detail_original = true; + model.use_responses_lite = false; + } model .model_messages .as_mut() @@ -268,12 +277,17 @@ async fn review_respects_complete_context_budget( if matches!( reviewer_response, ReviewerResponse::ToolContinuation + | ReviewerResponse::FileImageContinuation | ReviewerResponse::UncompactableContinuation | ReviewerResponse::CompactionError ) { builder = builder .with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?); } + let image_store = Arc::new(RecordingFileAttachmentStore::default()); + if matches!(reviewer_response, ReviewerResponse::FileImageContinuation) { + builder = builder.with_image_store(image_store.clone()); + } let test = builder.build_with_auto_env(&server).await?; let command = json!({"cmd": "echo guardian-budget-test", "sandbox_permissions": "require_escalated", "justification": "Run the requested command."}).to_string(); let mut commentary = @@ -321,12 +335,22 @@ async fn review_respects_complete_context_budget( "exec", "text('inspection-output'.repeat(600));", ), + // A tiny inline image fits before upload. Its opaque original-detail file + // reference must reserve 10k tokens in reviewer history and force compaction. + ReviewerResponse::FileImageContinuation => ev_custom_tool_call( + "reviewer-inspect", + "exec", + r#"image("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "original");"#, + ), }, ev_completed("review"), ]), ); } - if matches!(reviewer_response, ReviewerResponse::ToolContinuation) { + if matches!( + reviewer_response, + ReviewerResponse::ToolContinuation | ReviewerResponse::FileImageContinuation + ) { events.insert( /*index*/ 2, sse(vec![ @@ -424,18 +448,38 @@ async fn review_respects_complete_context_budget( "expected required-evidence budget rejection: {output}" ); } else { - let recovered = matches!(reviewer_response, ReviewerResponse::ToolContinuation); + let recovered = matches!( + reviewer_response, + ReviewerResponse::ToolContinuation | ReviewerResponse::FileImageContinuation + ); assert_eq!(requests.len(), if recovered { 4 } else { 3 }); assert_eq!(guardian_requests.len(), if recovered { 2 } else { 1 }); if recovered { assert_eq!(compact_requests.len(), 1); let compact = &compact_requests[0]; - assert!( - compact - .body_json() - .to_string() - .contains("inspection-output") - ); + if matches!(reviewer_response, ReviewerResponse::FileImageContinuation) { + assert_eq!( + image_store + .uploads + .lock() + .expect("image upload tracker lock is not poisoned") + .len(), + 1 + ); + // The file reservation also protects the compaction request itself: an output + // larger than its window is replaced before the summary request is sent. + assert_eq!( + compact.custom_tool_call_output("reviewer-inspect")["output"], + "Output exceeded the available model context and was truncated" + ); + } else { + assert!( + compact + .body_json() + .to_string() + .contains("inspection-output") + ); + } let recovered = guardian_requests[1].body_json(); assert!( recovered["input"] diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index b30ad11aa955..fb749dc813ca 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -57,8 +57,8 @@ use std::time::Duration; const UPLOADED_FILE_ID: &str = "file_uploaded_image"; #[derive(Default)] -struct RecordingFileAttachmentStore { - uploads: Mutex>, +pub(super) struct RecordingFileAttachmentStore { + pub(super) uploads: Mutex>, } impl AttachmentStore for RecordingFileAttachmentStore { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 2f4c531b2976..c2454b80088a 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -1677,6 +1677,8 @@ async fn contributor_includes_transcript_images_by_default() -> Result<()> { let user_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGPgEpEDAABoAD1UCKP3AAAAAElFTkSuQmCC"; let tool_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQE+ECAACQAD304kFaAAAAAElFTkSuQmCC"; + let user_file_id = "file_user"; + let tool_file_id = "file_tool"; let history = vec![ ResponseItem::Message { id: None, @@ -1691,6 +1693,12 @@ async fn contributor_includes_transcript_images_by_default() -> Result<()> { }, detail: Some(ImageDetail::High), }, + ContentItem::InputImage { + image: ImageReference::File { + file_id: user_file_id.to_owned(), + }, + detail: Some(ImageDetail::High), + }, ], phase: None, internal_chat_message_metadata_passthrough: None, @@ -1719,6 +1727,12 @@ async fn contributor_includes_transcript_images_by_default() -> Result<()> { }, detail: Some(ImageDetail::High), }, + FunctionCallOutputContentItem::InputImage { + image: ImageReference::File { + file_id: tool_file_id.to_owned(), + }, + detail: Some(ImageDetail::High), + }, ]), internal_chat_message_metadata_passthrough: None, }, @@ -1740,16 +1754,24 @@ enabled = true .expect("Luna user content should be an array"); assert_eq!( - content[content.len() - 2..], + content[content.len() - 4..], [ json!({ "type": "input_image", "image_url": user_image, }), + json!({ + "type": "input_image", + "file_id": user_file_id, + }), json!({ "type": "input_image", "image_url": tool_image, }), + json!({ + "type": "input_image", + "file_id": tool_file_id, + }), ] ); diff --git a/codex-rs/guardian-context/src/budget.rs b/codex-rs/guardian-context/src/budget.rs index 1ebec846e4ca..06db2a906dfb 100644 --- a/codex-rs/guardian-context/src/budget.rs +++ b/codex-rs/guardian-context/src/budget.rs @@ -108,7 +108,9 @@ impl SectionCost { ContentItem::InputImage { image: ImageReference::File { .. }, .. - } => {} + } => { + self.image_count = self.image_count.saturating_add(1); + } ContentItem::InputAudio { audio_url } => { // Guardian currently has no audio contributor. Count a future opaque // payload conservatively until its consumer supplies modality costs. @@ -214,17 +216,16 @@ pub(super) fn content_framing_tokens(item_count: usize) -> usize { fn adjusted_tokens(mut bytes: usize, content: &[ContentItem]) -> usize { for item in content { - if let ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - .. - } = item - { - let payload = ByteCount::measure(|counter| serde_json::to_writer(counter, image_url)); - if payload == usize::MAX { - return usize::MAX; + if let ContentItem::InputImage { image, .. } = item { + if let ImageReference::Inline { image_url } = image { + let payload = + ByteCount::measure(|counter| serde_json::to_writer(counter, image_url)); + if payload == usize::MAX { + return usize::MAX; + } + bytes = bytes.saturating_sub(payload.saturating_sub(2)); } bytes = bytes - .saturating_sub(payload.saturating_sub(2)) .saturating_add(TruncationPolicy::Tokens(IMAGE_TOKEN_RESERVATION).byte_budget()); } } diff --git a/codex-rs/guardian-context/src/budget_tests.rs b/codex-rs/guardian-context/src/budget_tests.rs index 33fc174ad7a5..8ce4fbb4a698 100644 --- a/codex-rs/guardian-context/src/budget_tests.rs +++ b/codex-rs/guardian-context/src/budget_tests.rs @@ -23,6 +23,12 @@ fn section_costs_keep_multimodal_payloads_separate() { }, detail: None, }), + Budgeted::required(ContentItem::InputImage { + image: ImageReference::File { + file_id: "file_123".to_owned(), + }, + detail: None, + }), ]), }, SectionOutput { @@ -44,7 +50,7 @@ fn section_costs_keep_multimodal_payloads_separate() { SectionCost { text_bytes: "évidence".len(), image_bytes: "data:image/png;base64,AAAA".len(), - image_count: 1 + image_count: 2 } ), ( @@ -71,6 +77,14 @@ fn request_estimate_reserves_images_independently_of_encoded_size() { let short = estimate_input_tokens(&image("AAAA")); assert_eq!(short, estimate_input_tokens(&image(&"A".repeat(200_000)))); assert!(short >= IMAGE_TOKEN_RESERVATION); + + let file = message(vec![ContentItem::InputImage { + image: ImageReference::File { + file_id: "file_123".to_owned(), + }, + detail: Some(codex_protocol::models::ImageDetail::Original), + }]); + assert!(estimate_input_tokens(&file) >= IMAGE_TOKEN_RESERVATION); } #[test] diff --git a/codex-rs/guardian-context/src/composition.rs b/codex-rs/guardian-context/src/composition.rs index 66892875fd5c..25450d989d2a 100644 --- a/codex-rs/guardian-context/src/composition.rs +++ b/codex-rs/guardian-context/src/composition.rs @@ -292,17 +292,7 @@ impl ComposedContext { })); continue; } - ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } => UserInput::Image { - image: ImageReference::Inline { image_url }, - detail, - }, - ContentItem::InputImage { - image: ImageReference::File { .. }, - .. - } => continue, + ContentItem::InputImage { image, detail } => UserInput::Image { image, detail }, ContentItem::InputAudio { .. } | ContentItem::OutputText { .. } => { return Err(SectionError::UnsupportedDelivery { section: section.id, @@ -330,10 +320,6 @@ impl ComposedContext { } })); } - ContentItem::InputImage { - image: ImageReference::File { .. }, - .. - } => {} content => user_content.push(content), } } diff --git a/codex-rs/guardian-context/src/composition_tests.rs b/codex-rs/guardian-context/src/composition_tests.rs index 4095f007482d..ed0fdf41c191 100644 --- a/codex-rs/guardian-context/src/composition_tests.rs +++ b/codex-rs/guardian-context/src/composition_tests.rs @@ -1,4 +1,6 @@ use super::*; +use codex_protocol::models::ImageDetail; +use codex_protocol::models::ImageReference; use pretty_assertions::assert_eq; #[test] @@ -56,12 +58,21 @@ fn delivery_preserves_arbitrary_message_boundaries_and_rejects_them_for_sync() { } #[test] -fn long_text_delivery_is_lossless_bounded_and_fully_budgeted() { +fn long_text_and_file_image_delivery_is_lossless_bounded_and_fully_budgeted() { let text = "é🙂\"\n".repeat(/*n*/ 20_000); + let file_image = ImageReference::File { + file_id: "file_123".to_owned(), + }; let context = ComposedContext { sections: vec![SectionOutput { id: "planned_action", - delivery: text_content(vec![text.clone()]), + delivery: SectionDelivery::UserContent(vec![ + Budgeted::required(ContentItem::InputText { text: text.clone() }), + Budgeted::required(ContentItem::InputImage { + image: file_image.clone(), + detail: Some(ImageDetail::High), + }), + ]), }], truncations: Vec::new(), }; @@ -70,7 +81,15 @@ fn long_text_delivery_is_lossless_bounded_and_fully_budgeted() { let ResponseItem::Message { content, .. } = &messages[0] else { panic!("user message") }; - let parts = content + let (image, text_content) = content.split_last().expect("message content"); + assert_eq!( + image, + &ContentItem::InputImage { + image: file_image.clone(), + detail: Some(ImageDetail::High), + } + ); + let parts = text_content .iter() .map(|item| match item { ContentItem::InputText { text } => text.as_str(), @@ -84,14 +103,16 @@ fn long_text_delivery_is_lossless_bounded_and_fully_budgeted() { .all(|part| part.len() <= TruncationPolicy::Tokens(9_000).byte_budget()) ); assert!(estimated >= crate::estimate_input_tokens(&messages[0])); - assert_eq!( - context.into_user_inputs().unwrap(), - parts - .into_iter() - .map(|part| UserInput::Text { - text: part.to_owned(), - text_elements: Vec::new(), - }) - .collect::>() - ); + let mut expected_inputs = parts + .into_iter() + .map(|part| UserInput::Text { + text: part.to_owned(), + text_elements: Vec::new(), + }) + .collect::>(); + expected_inputs.push(UserInput::Image { + image: file_image, + detail: Some(ImageDetail::High), + }); + assert_eq!(context.into_user_inputs().unwrap(), expected_inputs); } diff --git a/codex-rs/guardian-context/src/enforcement.rs b/codex-rs/guardian-context/src/enforcement.rs index 4e59d734637c..234f0d2fbdff 100644 --- a/codex-rs/guardian-context/src/enforcement.rs +++ b/codex-rs/guardian-context/src/enforcement.rs @@ -88,17 +88,13 @@ pub enum HistoryTruncation { impl ComposedContext { /// Applies host image admission before aggregate selection, preserving section /// identity and each retained item's selection policy. - pub fn retain_images(&mut self, mut admit: impl FnMut(&str, &mut Option) -> bool) { + pub fn retain_images( + &mut self, + mut admit: impl FnMut(&ImageReference, &mut Option) -> bool, + ) { for section in &mut self.sections { retain_content(section, &mut self.truncations, |_, item| match item { - ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } => admit(image_url, detail), - ContentItem::InputImage { - image: ImageReference::File { .. }, - .. - } => false, + ContentItem::InputImage { image, detail } => admit(image, detail), _ => true, }); } diff --git a/codex-rs/guardian-context/src/enforcement_tests.rs b/codex-rs/guardian-context/src/enforcement_tests.rs index 88599eb177d1..4c3045753b94 100644 --- a/codex-rs/guardian-context/src/enforcement_tests.rs +++ b/codex-rs/guardian-context/src/enforcement_tests.rs @@ -236,8 +236,14 @@ fn budget_reserves_existing_context_and_preserves_required_messages() { } #[test] -fn image_omission_preserves_text_and_later_eviction_policy() { +fn image_accounting_preserves_later_eviction_policy() { let evidence = text(&"optional commentary ".repeat(/*n*/ 100)); + let file_image = ContentItem::InputImage { + image: ImageReference::File { + file_id: "file_123".to_owned(), + }, + detail: None, + }; let mut context = ComposedContext { sections: vec![SectionOutput { id: "evidence", @@ -251,6 +257,7 @@ fn image_omission_preserves_text_and_later_eviction_policy() { }, BudgetPriority::Image, ), + Budgeted::optional(file_image.clone(), BudgetPriority::Image), Budgeted::optional(evidence.clone(), BudgetPriority::Commentary), Budgeted::required(text("user restriction")), ]), @@ -261,7 +268,7 @@ fn image_omission_preserves_text_and_later_eviction_policy() { .clone() .enforce_budget( RequestBudget { - max_input_tokens: 1_000, + max_input_tokens: content_tokens(&file_image).saturating_add(1_000), existing_context_tokens: 0, }, "evidence omitted".to_owned(), @@ -271,12 +278,12 @@ fn image_omission_preserves_text_and_later_eviction_policy() { assert_eq!( without_oversized_image.into_messages(), vec![user_message(vec![ - evidence.clone(), + file_image.clone(), text("user restriction"), text("evidence omitted") ])] ); - context.retain_images(|_, _| false); + context.retain_images(|image, _| matches!(image, ImageReference::File { .. })); let available = context.estimated_tokens(); let retained = context .clone() @@ -291,12 +298,16 @@ fn image_omission_preserves_text_and_later_eviction_policy() { .unwrap(); assert_eq!( retained.into_messages(), - vec![user_message(vec![evidence, text("user restriction")])] + vec![user_message(vec![ + file_image.clone(), + evidence, + text("user restriction") + ])] ); let smaller = context .enforce_budget( RequestBudget { - max_input_tokens: 100, + max_input_tokens: content_tokens(&file_image).saturating_add(100), existing_context_tokens: 0, }, "evidence omitted".to_owned(), @@ -306,6 +317,7 @@ fn image_omission_preserves_text_and_later_eviction_policy() { assert_eq!( smaller.into_messages(), vec![user_message(vec![ + file_image, text("user restriction"), text("evidence omitted") ])] diff --git a/codex-rs/guardian-context/src/images.rs b/codex-rs/guardian-context/src/images.rs index 27314df4dbcf..507e6882156c 100644 --- a/codex-rs/guardian-context/src/images.rs +++ b/codex-rs/guardian-context/src/images.rs @@ -1,5 +1,6 @@ //! Bounded image selection shared by Guardian consumers. //! Keeps source order and evicts oldest images using the existing count/byte caps. +//! Byte caps cover inline URLs and file ID strings, not referenced file contents. //! Consumers still choose source visibility and model-specific image detail. use crate::ContextSection; @@ -29,6 +30,7 @@ pub struct TranscriptImageInput<'a> { #[derive(Clone, Default, PartialEq)] pub struct TranscriptImages { pub images: Vec, + /// Omitted inline payload or file ID bytes, not the size of referenced files. pub omitted_bytes: usize, } @@ -56,37 +58,37 @@ impl TranscriptImages { } let mut images = VecDeque::new(); - let mut image_bytes = 0usize; + let mut image_reference_bytes = 0usize; let mut omitted_bytes = 0usize; - let mut include_image = |image_url: &str, detail: Option| { - if image_url.len() > MAX_TRANSCRIPT_IMAGE_BYTES { - omitted_bytes = omitted_bytes.saturating_add(image_url.len()); + let mut include_image = |image: &ImageReference, detail: Option| { + let reference_bytes = match image { + ImageReference::Inline { image_url } => image_url.len(), + ImageReference::File { file_id } => file_id.len(), + }; + if reference_bytes > MAX_TRANSCRIPT_IMAGE_BYTES { + omitted_bytes = omitted_bytes.saturating_add(reference_bytes); return; } while images.len() >= MAX_TRANSCRIPT_IMAGES - || image_bytes + image_url.len() > MAX_TRANSCRIPT_IMAGE_BYTES + || image_reference_bytes + reference_bytes > MAX_TRANSCRIPT_IMAGE_BYTES { - let Some(ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - .. - }) = images.pop_front() - else { + let Some(ContentItem::InputImage { image, .. }) = images.pop_front() else { break; }; - image_bytes -= image_url.len(); - omitted_bytes = omitted_bytes.saturating_add(image_url.len()); + let evicted_bytes = match image { + ImageReference::Inline { image_url } => image_url.len(), + ImageReference::File { file_id } => file_id.len(), + }; + image_reference_bytes = image_reference_bytes.saturating_sub(evicted_bytes); + omitted_bytes = omitted_bytes.saturating_add(evicted_bytes); } - image_bytes += image_url.len(); + image_reference_bytes = image_reference_bytes.saturating_add(reference_bytes); images.push_back(ContentItem::InputImage { - image: ImageReference::Inline { - image_url: image_url.to_owned(), - }, + image: image.clone(), detail, }); }; - // TODO(kc) Include file-backed images once Guardian can admit them without resolving or - // applying byte-based checks in Core. for item in items { match item { ResponseItem::Message { role, content, .. } @@ -94,15 +96,10 @@ impl TranscriptImages { { for item in content { match item { - ContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } => include_image(image_url, *detail), - ContentItem::InputImage { - image: ImageReference::File { .. }, - .. + ContentItem::InputImage { image, detail } => { + include_image(image, *detail) } - | ContentItem::InputText { .. } + ContentItem::InputText { .. } | ContentItem::InputAudio { .. } | ContentItem::OutputText { .. } => {} } @@ -115,15 +112,10 @@ impl TranscriptImages { if let Some(content) = output.content_items() { for item in content { match item { - FunctionCallOutputContentItem::InputImage { - image: ImageReference::Inline { image_url }, - detail, - } => include_image(image_url, *detail), - FunctionCallOutputContentItem::InputImage { - image: ImageReference::File { .. }, - .. + FunctionCallOutputContentItem::InputImage { image, detail } => { + include_image(image, *detail) } - | FunctionCallOutputContentItem::InputText { .. } + FunctionCallOutputContentItem::InputText { .. } | FunctionCallOutputContentItem::InputAudio { .. } | FunctionCallOutputContentItem::EncryptedContent { .. } => {} } @@ -137,9 +129,9 @@ impl TranscriptImages { for image in input.node_repl_images { match image { ContentItem::InputImage { - image: ImageReference::Inline { image_url }, + image: image @ ImageReference::Inline { .. }, detail, - } => include_image(image_url, *detail), + } => include_image(image, *detail), ContentItem::InputImage { image: ImageReference::File { .. }, .. diff --git a/codex-rs/guardian-context/src/images_tests.rs b/codex-rs/guardian-context/src/images_tests.rs index 5c3fa54d04ad..89361ffe83a4 100644 --- a/codex-rs/guardian-context/src/images_tests.rs +++ b/codex-rs/guardian-context/src/images_tests.rs @@ -1,6 +1,11 @@ use super::MAX_TRANSCRIPT_IMAGE_BYTES; use super::TranscriptImageInput; use super::TranscriptImages; +use crate::CollectedContext; +use crate::ContextPresentation; +use crate::ContextSection; +use crate::RenderedTranscript; +use crate::composition::user_message; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -99,20 +104,26 @@ fn image_selection_preserves_source_policy_order_and_both_limits() { ); } -/// File-backed images stay out of Guardian without consuming its inline-image capacity. +/// File-backed history images share count and reference-byte limits with inline images. #[test] -fn file_images_are_omitted_without_displacing_inline_images() { +fn file_images_are_selected_from_history_sources() { let file_image = ContentItem::InputImage { image: ImageReference::File { file_id: "file_123".to_string(), }, detail: Some(ImageDetail::High), }; + let tool_file_image = ContentItem::InputImage { + image: ImageReference::File { + file_id: "file_tool".to_string(), + }, + detail: Some(ImageDetail::High), + }; let history = [ ResponseItem::Message { id: None, role: "user".into(), - content: vec![image("first"), file_image.clone(), image("second")], + content: vec![image("first"), file_image.clone()], phase: None, internal_chat_message_metadata_passthrough: None, }, @@ -138,29 +149,106 @@ fn file_images_are_omitted_without_displacing_inline_images() { internal_chat_message_metadata_passthrough: None, }, ]; - let repl = [ - file_image, - image("fourth"), - ContentItem::InputImage { + let repl = [image("fourth")]; + + assert_eq!( + TranscriptImages::collect( + &history, + TranscriptImageInput { + enabled: true, + include_tool_outputs: true, + node_repl_images: &repl, + } + ), + TranscriptImages { + images: vec![file_image, tool_file_image, image("third"), image("fourth")], + omitted_bytes: "first".len(), + } + ); + + // Count-only eviction must remain observable even when no inline bytes were omitted. + let file_images = + ["file_1", "file_2", "file_3", "file_4", "file_5"].map(|file_id| ContentItem::InputImage { image: ImageReference::File { - file_id: "file_repl".to_string(), + file_id: file_id.to_owned(), }, detail: Some(ImageDetail::High), + }); + let history = [ResponseItem::Message { + id: None, + role: "user".into(), + content: file_images.to_vec(), + phase: None, + internal_chat_message_metadata_passthrough: None, + }]; + let selected = TranscriptImages::collect( + &history, + TranscriptImageInput { + enabled: true, + include_tool_outputs: true, + node_repl_images: &[], }, - ]; + ); + assert_eq!( + selected, + TranscriptImages { + images: file_images[1..].to_vec(), + omitted_bytes: "file_1".len(), + } + ); + let context = CollectedContext { + sections: vec![ContextSection::TranscriptImages(selected)], + } + .compose( + ContextPresentation::Async, + RenderedTranscript { + items: Vec::new(), + omission_note: None, + truncations: Vec::new(), + }, + ) + .unwrap(); + assert_eq!( + context + .truncations + .iter() + .map(|observation| ( + observation.component, + observation.original_bytes, + observation.retained_bytes, + )) + .collect::>(), + vec![("transcript_image", "file_1".len(), 0)] + ); + // Newer evidence evicts the full-budget ID; the oversized ID must not displace it. + let content = [ + ImageReference::File { + file_id: "x".repeat(MAX_TRANSCRIPT_IMAGE_BYTES), + }, + ImageReference::Inline { + image_url: "recent".into(), + }, + ImageReference::File { + file_id: "y".repeat(MAX_TRANSCRIPT_IMAGE_BYTES + 1), + }, + ] + .map(|image| ContentItem::InputImage { + image, + detail: Some(ImageDetail::High), + }); assert_eq!( TranscriptImages::collect( - &history, + &[user_message(content.to_vec())], TranscriptImageInput { enabled: true, include_tool_outputs: true, - node_repl_images: &repl, - } + node_repl_images: &[], + }, ), TranscriptImages { - images: ["first", "second", "third", "fourth"].map(image).to_vec(), - omitted_bytes: 0, + images: vec![image("recent")], + omitted_bytes: 2 * MAX_TRANSCRIPT_IMAGE_BYTES + 1, } ); } From 6749535c8f717749cd21374b321590b9975ab1f7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 01:31:47 +0000 Subject: [PATCH 011/373] Bound code-mode output previews across result blocks (#46073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Multiple result blocks and wrapped text can exceed the compact preview's row budget. Truncation must also preserve trailing failure diagnostics. ## What changed Apply a shared rendered-row limit to code-mode output previews after wrapping, including overlong URLs. Keep the first and last rows around an `… more · ctrl+t` indicator, while retaining the full text in the expanded transcript. ## Testing Add coverage for multiple result blocks, trailing failure diagnostics, Unicode wrapping, long URLs, and error results at several terminal widths, including transcript preservation. GitOrigin-RevId: ad9cf0fafb909e4df9a132f83722d6cd4e669687 --- codex-rs/tui/src/history_cell/mcp.rs | 18 +++ codex-rs/tui/src/history_cell/mcp_tests.rs | 135 +++++++++++++++++++++ codex-rs/tui/src/history_cell/tests.rs | 3 +- 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 3f0bc2a10657..60ae6b1efbd6 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -1,4 +1,6 @@ //! MCP tool-call, inventory, and output history cells. +//! Code-mode output previews share a rendered-row budget across all result blocks; +//! the expanded transcript retains the full text. use super::*; @@ -276,6 +278,22 @@ impl McpToolCallCell { } } + if compact { + // Adaptive wrapping keeps URLs intact; split overlong preview rows before counting + // them so a URL cannot exceed the budget. The transcript keeps the original URL. + detail_lines = crate::wrapping::word_wrap_lines(detail_lines, detail_wrap_width); + if detail_lines.len() > TOOL_CALL_MAX_LINES { + // Retain the tail so stdout cannot crowd out a trailing failure diagnostic. + let tail = detail_lines.split_off(detail_lines.len() - TOOL_CALL_MAX_LINES / 2); + detail_lines.truncate((TOOL_CALL_MAX_LINES - 1) / 2); + detail_lines.push(crate::line_truncation::truncate_line_to_width( + "… more · ctrl+t".dim().into(), + detail_wrap_width, + )); + detail_lines.extend(tail); + } + } + if !detail_lines.is_empty() { let initial_prefix: Span<'static> = if inline_invocation { " └ ".dim() diff --git a/codex-rs/tui/src/history_cell/mcp_tests.rs b/codex-rs/tui/src/history_cell/mcp_tests.rs index 5b59cf06da98..390b15de0a7a 100644 --- a/codex-rs/tui/src/history_cell/mcp_tests.rs +++ b/codex-rs/tui/src/history_cell/mcp_tests.rs @@ -133,6 +133,141 @@ fn result(content: Vec) -> CallToolResult { } } +#[test] +fn code_mode_output_shares_a_row_budget_across_blocks() { + let mut cell = new_active_mcp_tool_call( + "browser-call".to_string(), + McpInvocation { + server: "node_repl".to_string(), + tool: "js".to_string(), + arguments: Some(json!({"title": "Inspect page", "code": "await tab.snapshot()"})), + }, + /*animations_enabled*/ false, + ); + cell.complete( + Duration::ZERO, + Ok(result(vec![ + json!({"type": "text", "text": "Script completed\nOutput:\n"}), + json!({"type": "text", "text": "Page title\nNavigation\nMain content"}), + json!({"type": "text", "text": "Button\nLink\nFooter"}), + ])), + ); + + let display = cell + .display_lines(/*width*/ 40) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + insta::assert_snapshot!(display, @" + • Called Inspect page + └ Page title + Navigation + … more · ctrl+t + Link + Footer + "); + let transcript = cell + .transcript_lines(/*width*/ 100) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(transcript.contains("await tab.snapshot()")); + assert!(transcript.ends_with(" Button\n Link\n Footer")); +} + +#[test] +fn code_mode_output_keeps_trailing_failure_diagnostics() { + let mut cell = new_active_mcp_tool_call( + "browser-error".to_string(), + McpInvocation { + server: "node_repl".to_string(), + tool: "js".to_string(), + arguments: Some(json!({"title": "Inspect page"})), + }, + /*animations_enabled*/ false, + ); + cell.complete( + Duration::ZERO, + Ok(CallToolResult { + is_error: Some(true), + ..result(vec![ + json!({"type": "text", "text": "Script failed"}), + json!({"type": "text", "text": "Page title\nNavigation\nMain content\nButton\nLink\nFooter"}), + json!({"type": "text", "text": "Script error:\npermission denied"}), + ]) + }), + ); + let display = cell + .display_lines(/*width*/ 40) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + insta::assert_snapshot!(display, @" + • Called Inspect page + └ Script failed + Page title + … more · ctrl+t + Script error: + permission denied + "); +} + +#[test] +fn code_mode_output_row_budget_applies_after_wrapping_and_to_errors() { + let output = format!("{}\ntranscript tail", "Browser 页面 👩‍💻\n".repeat(40)); + for server in ["node_repl", "cua_repl"] { + for completion in [ + Ok(result(vec![json!({"type": "text", "text": output})])), + Ok(result(vec![json!({ + "type": "text", + "text": format!("https://example.com/{}\ntranscript tail", "页面".repeat(100)), + })])), + Ok(CallToolResult { + is_error: Some(true), + ..result(vec![json!({"type": "text", "text": output})]) + }), + Err(output.clone()), + ] { + let mut cell = new_active_mcp_tool_call( + "browser-call".to_string(), + McpInvocation { + server: server.to_string(), + tool: "js".to_string(), + arguments: Some(json!({"title": "Inspect"})), + }, + /*animations_enabled*/ false, + ); + cell.complete(Duration::ZERO, completion); + for width in [20, 40, 80] { + let display = cell.display_lines(width); + assert_eq!(display.len(), 1 + TOOL_CALL_MAX_LINES); + assert!( + display + .iter() + .all(|line| line.width() <= usize::from(width)) + ); + assert_eq!(display[3].to_string(), " … more · ctrl+t"); + let transcript = cell + .transcript_lines(width) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!( + transcript + .split_whitespace() + .collect::>() + .join(" ") + .contains("transcript tail") + ); + } + } + } +} + #[test] fn projected_content_preserves_width_dependent_rendering() { let text = "{\"result\": [1, 2, 3], \"text\": \"long output 🦀\"}"; diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 83855167596a..a4b963b328c0 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1444,8 +1444,7 @@ fn code_mode_tool_call_uses_title_and_preserves_full_transcript() { • Called Inspect Spotify workspace └ 012345678901234567890123456789012345 67890123456789012345678901234567 - 89012345678901234567890123456789 - 01234567890123456789012345678901 + … more · ctrl+t 23456789012345678901234567890123 45678901... From 800d183e2d0a3275c1b440992f4b11abbf425711 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 17 Sep 2026 01:40:46 +0000 Subject: [PATCH 012/373] Use captured step settings when spawning subagents (#46075) ## Why After a settings update during an active turn, spawned agents could inherit the turn's initial model and reasoning settings. Effort-only overrides could also be validated against the wrong model. ## What changed Build child configuration from the invoking step's captured model, effective reasoning effort, and reasoning summary in both multi-agent versions. Validate effort-only overrides against that captured model's supported reasoning levels. ## Testing Add regression coverage for spawning after an active-turn settings update in both multi-agent versions, including effort-only overrides. Update configuration tests to check captured settings and the model's default reasoning effort. GitOrigin-RevId: 068bdb744149048829cdaa68d254b62e96e6e6af --- codex-rs/core/src/agent/child_config.rs | 30 ++-- codex-rs/core/src/session/multi_agents.rs | 7 +- codex-rs/core/src/session/world_state.rs | 4 +- .../src/tools/handlers/multi_agents/spawn.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 42 +++++- .../tools/handlers/multi_agents_v2/spawn.rs | 2 +- codex-rs/core/tests/suite/step_settings.rs | 2 + .../suite/step_settings/agent_spawn_tests.rs | 128 ++++++++++++++++++ .../tests/suite/subagent_notifications.rs | 6 + 9 files changed, 196 insertions(+), 27 deletions(-) create mode 100644 codex-rs/core/tests/suite/step_settings/agent_spawn_tests.rs diff --git a/codex-rs/core/src/agent/child_config.rs b/codex-rs/core/src/agent/child_config.rs index 14933785909e..ca2f7d91759b 100644 --- a/codex-rs/core/src/agent/child_config.rs +++ b/codex-rs/core/src/agent/child_config.rs @@ -1,4 +1,4 @@ -//! Prepares child configuration from the invoking turn and requested overrides. +//! Prepares child configuration from captured step settings and requested overrides. //! //! Spawn and reload share live runtime policy; role and model precedence, full-history //! inheritance, and validation messages remain the same for each multi-agent version. @@ -7,6 +7,7 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; use crate::config::Config; use crate::session::session::Session; +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; @@ -49,16 +50,18 @@ pub(crate) struct PreparedSpawnConfig { /// Resolves child settings before starting the thread, retaining the invoking tool's precedence. pub(crate) async fn prepare_agent_spawn_config( session: &Session, - turn: &TurnContext, + step_context: &StepContext, options: SpawnConfigOptions<'_>, ) -> Result { - let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn)?; + let turn = step_context.turn.as_ref(); + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, step_context)?; if options.version == SpawnConfigVersion::V1 && options.full_history_fork { reject_full_fork_agent_type_override(options.role_name)?; } apply_requested_spawn_agent_model_overrides( session, - turn, + step_context, &mut config, options.model, options.reasoning_effort, @@ -99,15 +102,19 @@ pub(crate) async fn prepare_agent_spawn_config( /// Builds the base config snapshot for a newly spawned sub-agent. /// /// The returned config starts from the parent's effective config and then refreshes the -/// runtime-owned fields carried by the turn, including model selection, reasoning settings, -/// approval policy, sandbox, and cwd. Role-specific overrides are layered +/// model selection and reasoning settings captured for the invoking step, plus the turn's +/// runtime approval policy, sandbox, and cwd. Role-specific overrides are layered /// after this step; skipping this helper and cloning stale config state directly can send the child /// agent out with the wrong provider or runtime policy. pub(crate) fn build_agent_spawn_config( base_instructions: &BaseInstructions, - turn: &TurnContext, + step_context: &StepContext, ) -> Result { - let mut config = build_agent_shared_config(turn)?; + let mut config = build_agent_shared_config(step_context.turn.as_ref())?; + let settings = &step_context.settings; + config.model = Some(settings.model_info.slug.clone()); + config.model_reasoning_effort = settings.effective_reasoning_effort(); + config.model_reasoning_summary = Some(settings.reasoning_summary); config.base_instructions = Some(base_instructions.text.clone()); config.base_instructions_provenance = base_instructions.provenance.clone(); Ok(config) @@ -188,11 +195,12 @@ fn apply_spawn_agent_runtime_overrides( async fn apply_requested_spawn_agent_model_overrides( session: &Session, - turn: &TurnContext, + step_context: &StepContext, config: &mut Config, requested_model: Option<&str>, requested_reasoning_effort: Option, ) -> Result<(), String> { + let turn = step_context.turn.as_ref(); let requested_model = requested_model.or(turn.config.agent_default_subagent_model.as_deref()); let requested_reasoning_effort = requested_reasoning_effort .or_else(|| turn.config.agent_default_subagent_reasoning_effort.clone()); @@ -234,8 +242,8 @@ async fn apply_requested_spawn_agent_model_overrides( if let Some(reasoning_effort) = requested_reasoning_effort { validate_spawn_agent_reasoning_effort( - &turn.model_info().slug, - &turn.model_info().supported_reasoning_levels, + &step_context.settings.model_info.slug, + &step_context.settings.model_info.supported_reasoning_levels, &reasoning_effort, )?; config.model_reasoning_effort = Some(reasoning_effort); diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 4f6a594348ac..51e43a4dc6e9 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -16,10 +16,7 @@ pub(crate) struct ResolvedMultiAgentV2UsageHints { pub(crate) subagent: Option, } -pub(super) fn usage_hint_text( - step_context: &StepContext, - session_source: &SessionSource, -) -> Option { +pub(super) fn usage_hint_text(step_context: &StepContext) -> Option { let turn_context = step_context.turn.as_ref(); if turn_context.multi_agent_version != MultiAgentVersion::V2 { return None; @@ -32,7 +29,7 @@ pub(super) fn usage_hint_text( multi_agent_messages, !turn_context.config.update_plan_enabled && turn_context.config.model_catalog.is_none(), ); - match session_source { + match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => snapshot.subagent, SessionSource::Cli | SessionSource::VSCode diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 8ea7a66d84be..3b3015b6a316 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -275,9 +275,7 @@ impl Session { let mut multi_agent_mode = MultiAgentModeState::new( super::multi_agents::effective_multi_agent_mode(step_context), ); - if let Some(usage_hint_text) = - super::multi_agents::usage_hint_text(step_context, &turn_context.session_source) - { + if let Some(usage_hint_text) = super::multi_agents::usage_hint_text(step_context) { let usage_hint = MultiAgentUsageHintState::new(usage_hint_text); multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint); world_state.add_section(usage_hint); diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 73d63fab4273..d5fccaa57a99 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -94,7 +94,7 @@ async fn handle_spawn_agent( .await; let prepared = prepare_agent_spawn_config( &session, - turn.as_ref(), + step_context.as_ref(), SpawnConfigOptions { version: SpawnConfigVersion::V1, full_history_fork: args.fork_context, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 75b57dfe1e94..86d80ee720fb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -14,6 +14,8 @@ use crate::init_state_db; use crate::local_agent_graph_store_from_state_db; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; +use crate::session::tests::update_selected_settings_for_test; +use crate::session::tests::update_turn_settings_for_test; use crate::session::turn_context::TurnContext; use crate::session_prefix::format_inter_agent_completion_message; use crate::thread_manager::thread_store_from_config; @@ -37,6 +39,7 @@ use codex_models_manager::manager::StaticModelsManager; use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::items::TurnItem; @@ -4405,7 +4408,9 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr #[test_case::test_case(false; "inactive_parent")] #[test_case::test_case(true; "active_parent")] #[tokio::test] -async fn build_agent_spawn_config_uses_turn_context_values(parent_enabled: bool) { +async fn build_agent_spawn_config_uses_captured_step_settings_and_turn_context_values( + parent_enabled: bool, +) { fn pick_allowed_sandbox_policy( permissions: &crate::config::Permissions, base: SandboxPolicy, @@ -4430,10 +4435,18 @@ async fn build_agent_spawn_config_uses_turn_context_values(parent_enabled: bool) } let (_session, mut turn) = make_session_and_context().await; + update_turn_settings_for_test(&mut turn, |settings| { + update_selected_settings_for_test(settings, |selected| { + selected.collaboration_mode.settings.model = "stale-turn-model".to_string(); + selected.collaboration_mode.settings.reasoning_effort = Some(ReasoningEffort::Low); + }); + Arc::make_mut(&mut settings.model_info).slug = "stale-turn-model".to_string(); + settings.reasoning_summary = ReasoningSummary::Concise; + }); let base_instructions = BaseInstructions { text: "base".to_string(), provenance: Some(BaseInstructionsProvenance::Model { - model: turn.model_info().slug.clone(), + model: "captured-step-model".to_string(), }), }; turn.developer_instructions = Some("dev".to_string()); @@ -4495,13 +4508,30 @@ async fn build_agent_spawn_config_uses_turn_context_values(parent_enabled: bool) .get_or_insert_default() .guidance_message = Some("Parent model's resolved guidance.".to_string()); - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + let mut step_context = StepContext::for_test(Arc::new(turn)); + let settings = Arc::make_mut( + &mut Arc::get_mut(&mut step_context) + .expect("step context should not be shared") + .settings, + ); + update_selected_settings_for_test(settings, |selected| { + selected.collaboration_mode.settings.model = "captured-step-model".to_string(); + selected.collaboration_mode.settings.reasoning_effort = None; + }); + let model_info = Arc::make_mut(&mut settings.model_info); + model_info.slug = "captured-step-model".to_string(); + model_info.default_reasoning_level = Some(ReasoningEffort::High); + settings.reasoning_summary = ReasoningSummary::Detailed; + + let turn = step_context.turn.as_ref(); + let config = + build_agent_spawn_config(&base_instructions, step_context.as_ref()).expect("spawn config"); expected.base_instructions_provenance = base_instructions.provenance.clone(); expected.base_instructions = Some(base_instructions.text); - expected.model = Some(turn.model_info().slug.clone()); + expected.model = Some("captured-step-model".to_string()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort().cloned(); - expected.model_reasoning_summary = Some(turn.reasoning_summary()); + expected.model_reasoning_effort = Some(ReasoningEffort::High); + expected.model_reasoning_summary = Some(ReasoningSummary::Detailed); expected.developer_instructions = turn.developer_instructions.clone(); #[allow(deprecated)] { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d7be23ce58e0..72c5ccd52794 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -129,7 +129,7 @@ async fn handle_spawn_agent( let child_depth = next_thread_spawn_depth(&session_source); let prepared = prepare_agent_spawn_config( &session, - turn.as_ref(), + step_context.as_ref(), SpawnConfigOptions { version: SpawnConfigVersion::V2, full_history_fork: matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)), diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs index afda72bf8ff1..04938cd41a02 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -109,6 +109,8 @@ use test_case::test_case; use super::rmcp_client::remote_aware_environment_id; use super::rmcp_client::remote_aware_stdio_server_bin; +#[path = "step_settings/agent_spawn_tests.rs"] +mod agent_spawn; mod code_mode_notifications; const MODEL_A: &str = "step-settings-a"; diff --git a/codex-rs/core/tests/suite/step_settings/agent_spawn_tests.rs b/codex-rs/core/tests/suite/step_settings/agent_spawn_tests.rs new file mode 100644 index 000000000000..db87497641db --- /dev/null +++ b/codex-rs/core/tests/suite/step_settings/agent_spawn_tests.rs @@ -0,0 +1,128 @@ +//! Spawned agents inherit the invoking step's model settings after active-turn updates. + +use super::*; +use pretty_assertions::assert_eq; +use test_case::test_case; + +#[test_case(false, None; "v1 inherits captured settings")] +#[test_case(true, None; "v2 inherits captured settings")] +#[test_case(false, Some(ReasoningEffort::Medium); "v1 validates effort against captured model")] +#[test_case(true, Some(ReasoningEffort::Medium); "v2 validates effort against captured model")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_inherits_captured_settings_after_a_turn_update( + multi_agent_v2: bool, + requested_effort: Option, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut spawn_arguments = json!({ "message": "Complete the delegated task." }); + let namespace = if multi_agent_v2 { + spawn_arguments["task_name"] = json!("worker"); + spawn_arguments["fork_turns"] = json!("none"); + "collaboration" + } else { + "multi_agent_v1" + }; + if let Some(effort) = &requested_effort { + spawn_arguments["reasoning_effort"] = json!(effort); + } + let response_mock = mount_sse_sequence( + &server, + vec![ + paused_response("resp-original", "pause-before-spawn"), + sse(vec![ + ev_response_created("resp-spawn"), + ev_function_call_with_namespace( + "spawn-worker", + namespace, + "spawn_agent", + &spawn_arguments.to_string(), + ), + ev_completed("resp-spawn"), + ]), + sse_completed("resp-first-completion"), + sse_completed("resp-second-completion"), + ], + ) + .await; + // Child completion can trigger another parent request after the parent finishes. + mount_sse_once(&server, sse_completed("resp-child-notification")).await; + let test = step_settings_test() + .with_config(move |config| { + config + .features + .enable(Feature::Collab) + .expect("enable collab"); + if multi_agent_v2 { + config + .features + .enable(Feature::MultiAgentV2) + .expect("enable V2"); + } else { + config + .features + .disable(Feature::MultiAgentV2) + .expect("disable V2"); + } + for model in &mut config.model_catalog.as_mut().expect("model catalog").models { + // An effort-only override must be validated against B, not the turn's initial A. + model.supported_reasoning_levels = if model.slug == MODEL_B { + vec![ReasoningEffort::Medium, ReasoningEffort::High] + } else { + vec![ReasoningEffort::Low] + } + .into_iter() + .map(|effort| ReasoningEffortPreset { + description: effort.to_string(), + effort, + }) + .collect(); + model.supports_reasoning_summary_parameter = true; + model.use_responses_lite = false; + } + }) + .build_with_auto_env(&server) + .await?; + let mut created_threads = test.thread_manager.subscribe_thread_created(); + let request = start_paused_turn(&test.codex).await?; + apply_turn_settings( + &test.codex, + &request.turn_id, + TurnSettingsUpdate { + model: Some(MODEL_B.to_string()), + effort: Some(Some(ReasoningEffort::High)), + summary: Some(ReasoningSummary::Detailed), + ..Default::default() + }, + ) + .await?; + answer_paused_turn(&test.codex, &request.turn_id).await?; + + let child_thread_id = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 10), + created_threads.recv(), + ) + .await??; + let child_thread = test.thread_manager.get_thread(child_thread_id).await?; + for thread in [child_thread.as_ref(), test.codex.as_ref()] { + wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; + } + + let expected_effort = requested_effort.unwrap_or(ReasoningEffort::High); + let requests = response_mock.requests(); + let child_request = requests + .iter() + .find(|request| request.header("thread-id") == Some(child_thread_id.to_string())) + .expect("child should make a model request"); + assert_eq!( + request_settings(child_request), + json!({ + "model": MODEL_B, + "reasoning": { "effort": expected_effort, "summary": "detailed" }, + "service_tier": null, + }) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index bbd7113cf074..2139d7588298 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -67,6 +67,7 @@ use tokio::time::Instant; use tokio::time::sleep; use tokio::time::timeout; use tracing::Level; +use tracing_subscriber::layer::SubscriberExt; use tracing_test::internal::MockWriter; use wiremock::Mock; use wiremock::MockServer; @@ -2212,6 +2213,11 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child( .with_writer(MockWriter::new(output)) .finish(); let _guard = tracing::subscriber::set_default(subscriber); + // Keep two dispatchers alive so a parallel test that first registers a communication + // callsite without a subscriber cannot globally disable it for our capturing subscriber. + let _parallel_dispatch = tracing::Dispatch::new( + tracing_subscriber::registry().with(tracing_subscriber::filter::LevelFilter::OFF), + ); let server = start_mock_server().await; let message = if plaintext { From 1e9564fb859c7543404ede96ba5d92f1ef7faefc Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 01:46:35 +0000 Subject: [PATCH 013/373] Keep the composer responsive during Command Center session creation (#46077) ## Why Creating a session from Command Center waits for configuration and server requests before displaying the composer. Scanning loaded agents also adds unnecessary round trips for a new session with no descendants. ## What changed - Show an editable startup composer while loading settings, starting the thread, and attaching the new session. - Transfer the draft into the new session, including pending paste state, and retain edits for a retry if setup fails. - Keep session creation running when cancellation keys are pressed in the provisional composer. - Skip descendant backfill and paused-goal resume prompts for new sessions. GitOrigin-RevId: 8371a6c3edc09de6366253021eae99f7b60d78ef --- codex-rs/tui/src/app/agents_overview.rs | 85 ++++++++++++++---- codex-rs/tui/src/app/agents_overview_new.rs | 81 +++++++++++++---- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/resume_config.rs | 48 +++++++--- .../tests/background_task_defaults_tests.rs | 8 ++ codex-rs/tui/src/startup_draft.rs | 87 +++++++++++++------ 6 files changed, 238 insertions(+), 73 deletions(-) diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 3b239b77f873..65d8442cd9d6 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -21,6 +21,7 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; use crate::chatwidget::ThreadInputStateRestoreMode; +use crate::startup_draft::StartupDraftPump; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadHistoryMode; @@ -54,6 +55,7 @@ pub(super) struct AgentsOverviewState { /// Keep new tasks subscribed and reusable until a first turn makes them resumable. pub(super) blank_sessions: HashMap, pub(super) input_states: HashMap, + pub(super) new_session_draft: Option>, pub(super) dispatched_requests: HashMap>, } @@ -310,8 +312,10 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result { - Box::pin(self.attach_agents_overview_thread(tui, app_server, thread_id, /*started*/ None)) - .await + Box::pin(self.attach_agents_overview_thread( + tui, app_server, thread_id, /*started*/ None, /*startup_draft*/ None, + )) + .await } async fn attach_agents_overview_thread( @@ -320,10 +324,12 @@ impl App { app_server: &mut AppServerSession, root_thread_id: ThreadId, started: Option<(Config, crate::app_server_session::AppServerStartedThread)>, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> color_eyre::Result { if self.windows_sandbox_blocks_thread_switch() { return Ok(AppRunControl::Continue); } + let is_new_session = started.is_some(); if self.current_displayed_thread_id() == Some(root_thread_id) && (!self.thread_unavailable(root_thread_id) || self.chat_widget.is_external_writer_view()) @@ -392,9 +398,12 @@ impl App { .insert(active_thread_id, input_state); } - let target_thread = match app_server - .thread_read(root_thread_id, /*include_turns*/ false) - .await + let target_thread = match StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + app_server.thread_read(root_thread_id, /*include_turns*/ false), + ) + .await { Ok(thread) => thread, Err(error) => { @@ -456,6 +465,7 @@ impl App { &mut resume_config, target_thread.cwd.as_path(), Some(&target_thread), + /*startup_draft*/ None, ) .await { @@ -553,7 +563,18 @@ impl App { }; if !previous_running_thread_ids.is_empty() { for side_thread_id in Vec::from_iter(self.side_threads.keys().copied()) { - if !self.discard_side_thread(app_server, side_thread_id).await { + let discarded = match startup_draft.as_deref_mut() { + Some(draft) => { + draft + .run_until( + tui, + self.discard_side_thread(app_server, side_thread_id), + ) + .await? + } + None => self.discard_side_thread(app_server, side_thread_id).await, + }; + if !discarded { let _ = app_server.thread_unsubscribe(root_thread_id).await; return Ok(AppRunControl::Continue); } @@ -580,7 +601,14 @@ impl App { && !previous_displayed_thread_id .is_some_and(|id| self.agents_overview.blank_sessions.contains_key(&id)) { - self.shutdown_current_thread(app_server).await; + match startup_draft.as_deref_mut() { + Some(draft) => { + draft + .run_until(tui, self.shutdown_current_thread(app_server)) + .await? + } + None => self.shutdown_current_thread(app_server).await, + } } // Explicit choices carry across cold resumes and new sessions. self.runtime_approval_policy_override = @@ -656,10 +684,13 @@ impl App { .matches_config(&self.config)) .then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config)); } - if !self - .backfill_loaded_subagent_threads(app_server) - .await - .completed + // A new session has no descendants. Scanning every loaded thread here + // adds a serial round trip per agent before the composer can render. + if !is_new_session + && !self + .backfill_loaded_subagent_threads(app_server) + .await + .completed { self.backfill_loaded_subagent_threads(app_server).await; } @@ -668,7 +699,12 @@ impl App { && thread_id != root_thread_id && Some(thread_id) != previous_displayed_thread_id && !self.agents_overview.blank_sessions.contains_key(&thread_id) - && let Err(error) = app_server.thread_unsubscribe(thread_id).await + && let Err(error) = StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + app_server.thread_unsubscribe(thread_id), + ) + .await { tracing::warn!(%thread_id, %error, "failed to unsubscribe previous agent thread"); } @@ -705,7 +741,7 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } } - if !read_only { + if !read_only && !is_new_session { self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id) .await; } @@ -738,6 +774,7 @@ impl App { tui: &mut tui::Tui, app_server: &mut AppServerSession, cwd: Option, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> Option<(Config, Option)> { if self .chat_widget @@ -763,7 +800,13 @@ impl App { |cwd| cwd.to_path_buf(), ) }; - let mut config = match self.rebuild_config_for_cwd(local_cwd).await { + let mut config = match StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + self.rebuild_config_for_cwd(local_cwd), + ) + .await + { Ok(config) => config, Err(error) => { self.add_agents_overview_error(format!("Failed to load project settings: {error}")); @@ -781,6 +824,7 @@ impl App { &mut config, &trust_cwd, /*resumed_thread*/ None, + startup_draft.as_deref_mut(), ) .await .is_err() @@ -812,10 +856,17 @@ impl App { .or_else(|| app_server.remote_cwd_override()) .unwrap_or(Path::new(".")), }; + if let Some(draft) = startup_draft.as_deref_mut() { + draft.apply_config(&config); + } let mut server_model_cleared = false; - match crate::config_update::read_effective_config_if_supported( - app_server.request_handle(), - defaults_cwd, + match StartupDraftPump::run_with_optional_draft( + startup_draft, + tui, + crate::config_update::read_effective_config_if_supported( + app_server.request_handle(), + defaults_cwd, + ), ) .await { diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index 1d151f232b54..09039f120ccc 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -2,6 +2,9 @@ //! Worktrees start at the repository default branch and bind only the new session. use super::*; +use crate::startup_draft::StartupDraftInitialScreen; +use crate::startup_draft::StartupDraftPump; +use crate::startup_draft::StartupDraftSessionAction; /// Owns a freshly created checkout until the UI accepts its completion event. /// Dropping a cancelled worker or undelivered event removes only a clean checkout. @@ -39,13 +42,46 @@ impl App { app_server: &mut AppServerSession, cwd: Option, ) -> Result { + if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() { + return Ok(AppRunControl::Continue); + } + let previous_thread = self.current_displayed_thread_id(); + let mut draft = self + .agents_overview + .new_session_draft + .take() + .unwrap_or_else(|| { + Box::new(StartupDraftPump::new( + tui, + StartupDraftInitialScreen::Composer, + StartupDraftSessionAction::NewFromCommandCenter, + )) + }); + let mut display_config = self.chat_widget.config_ref().clone(); + if let Some(cwd) = cwd.as_ref() { + display_config.cwd = cwd.clone(); + } + draft.apply_config(&display_config); + tui.terminal.clear()?; // Keep the large session-start future off the TUI's stack in dev builds. - Box::pin( - self.start_agents_overview_session( - tui, app_server, cwd, /*managed_worktree*/ None, - ), - ) - .await + let result = Box::pin(self.start_agents_overview_session( + tui, + app_server, + cwd, + /*managed_worktree*/ None, + Some(&mut draft), + )) + .await; + if self.current_displayed_thread_id() != previous_thread { + draft.flush_pending_paste_newline(tui).await?; + self.chat_widget.restore_startup_draft(draft.take_draft()); + } else { + // Retain edits if setup fails so retrying `n` does not lose the draft. + draft.flush_pending_events(tui).await?; + self.agents_overview.new_session_draft = Some(draft); + } + tui.frame_requester().schedule_frame(); + result } pub(in crate::app) async fn start_agents_overview_session( @@ -57,6 +93,7 @@ impl App { codex_worktree::WorktreeManager, codex_worktree::ManagedWorktree, )>, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> Result { if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() { if let Some((_, checkout)) = &managed_worktree { @@ -68,7 +105,7 @@ impl App { return Ok(AppRunControl::Continue); } let Some((config, remote_cwd)) = self - .agents_overview_session_config(tui, app_server, cwd) + .agents_overview_session_config(tui, app_server, cwd, startup_draft.as_deref_mut()) .await else { if let Some((_, checkout)) = &managed_worktree { @@ -94,15 +131,22 @@ impl App { display_label: active.id, }) }); - let result = app_server - .start_thread_with_session_start_source( - &crate::local_settings::LocalSettings::from(&config), + let local_settings = crate::local_settings::LocalSettings::from(&config); + if let Some(draft) = startup_draft.as_deref_mut() { + draft.apply_config(&config); + } + let result = StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + Box::pin(app_server.start_thread_with_session_start_source( + &local_settings, &config, /*session_start_source*/ None, remote_cwd.as_deref(), selected_profile.as_ref(), - ) - .await; + )), + ) + .await; let started = match result { Ok(started) => started, Err(error) => { @@ -144,9 +188,14 @@ impl App { .insert(thread_id, started.clone()); // Use the dashboard's existing attachment path, which preserves running agents // and unsent input in the previous session. Do not send an initial turn. - let control = self - .attach_agents_overview_thread(tui, app_server, thread_id, Some((config, started))) - .await?; + let control = Box::pin(self.attach_agents_overview_thread( + tui, + app_server, + thread_id, + Some((config, started)), + startup_draft, + )) + .await?; if self.current_displayed_thread_id() != Some(thread_id) { self.agents_overview.blank_sessions.remove(&thread_id); let _ = app_server.thread_unsubscribe(thread_id).await; @@ -184,7 +233,7 @@ impl App { return; } let Some((config, _)) = self - .agents_overview_session_config(tui, app_server, cwd) + .agents_overview_session_config(tui, app_server, cwd, /*startup_draft*/ None) .await else { return; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 595b996be9ac..4421e5a1def8 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2472,7 +2472,7 @@ impl App { }; let manager = pending.manager.clone(); let cwd = AbsolutePathBuf::try_from(checkout.cwd.clone())?; - return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)))).await; + return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)), /*startup_draft*/ None)).await; } Err(error) => self.add_agents_overview_error(error), } diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 0c1352069f0d..1c32645b61c9 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -4,6 +4,7 @@ use super::*; use crate::onboarding::onboarding_screen::check_directory_trust; +use crate::startup_draft::StartupDraftPump; use crate::startup_hooks_review::StartupHooksReviewOutcome; use crate::startup_hooks_review::load_startup_hooks_review_entry; use crate::startup_hooks_review::maybe_run_startup_hooks_review; @@ -135,6 +136,7 @@ impl App { &mut resume_config.0, &trust_cwd, resumed_thread.as_ref(), + /*startup_draft*/ None, ) .await?; resume_config.1 = crate::local_settings::LocalSettings::from(&resume_config.0); @@ -148,6 +150,7 @@ impl App { config: &mut Config, cwd: &Path, resumed_thread: Option<&codex_app_server_protocol::Thread>, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> std::result::Result<(), AppRunControl> { // Keep the existing explicit remote --cd gate, including retries after cancellation. // Other remote destinations await authoritative trust-root metadata. @@ -166,7 +169,7 @@ impl App { &self.app_server_target, cwd, resumed_thread, - /*startup_draft*/ None, + startup_draft.as_deref_mut(), ) .await .map_err(|error| { @@ -187,21 +190,40 @@ impl App { return Err(AppRunControl::Continue); } if result.directory_trust_persisted && !app_server.uses_remote_workspace() { - *config = self - .rebuild_config_for_cwd(config.cwd.to_path_buf()) - .await - .map_err(|error| { - self.add_session_picker_error(format!( - "Failed to reload trusted folder settings: {error}" - )); - AppRunControl::Continue - })?; + *config = StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + self.rebuild_config_for_cwd(config.cwd.to_path_buf()), + ) + .await + .map_err(|error| { + self.add_session_picker_error(format!( + "Failed to reload trusted folder settings: {error}" + )); + AppRunControl::Continue + })?; if resumed_thread.is_none() { - let hooks = load_startup_hooks_review_entry( + let load_hooks = load_startup_hooks_review_entry( app_server.request_handle(), config.cwd.to_path_buf(), - ) - .await; + ); + let hooks = if let Some(draft) = startup_draft { + draft.apply_config(config); + async { + let hooks = draft.run_until(tui, load_hooks).await?; + draft.flush_pending_events(tui).await?; + Ok::<_, std::io::Error>(hooks) + } + .await + .map_err(|error| { + self.add_session_picker_error(format!( + "Unable to load folder hooks: {error}" + )); + AppRunControl::Continue + })? + } else { + load_hooks.await + }; match maybe_run_startup_hooks_review( app_server, tui, diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index ed16de3a9f89..e2ed7a59efe9 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -45,6 +45,7 @@ fn trust_launch_folder(app: &mut App) { #[tokio::test] async fn command_center_new_reads_server_defaults_for_actual_destination() -> Result<()> { let mut tui = make_test_tui()?; + tui.pause_events(); for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [ ("local", false, false, "launch", "server-model"), ("local", true, false, "destination", "destination-model"), @@ -263,6 +264,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re #[tokio::test] async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> Result<()> { let mut tui = make_test_tui()?; + tui.pause_events(); for (choice, expected_model, expected_effort) in [ ("saved", "server-model", "high"), ("cli_effort", "server-model", "low"), @@ -362,6 +364,7 @@ async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> #[tokio::test] async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> Result<()> { let mut tui = make_test_tui()?; + tui.pause_events(); for capability in [ HistoryCapabilities::ConfigReadFails, HistoryCapabilities::ThreadStartFails, @@ -459,6 +462,7 @@ async fn command_center_new_preserves_permissions_across_sessions() -> Result<() .await ); let mut tui = make_test_tui()?; + tui.pause_events(); for _ in 0..2 { app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; @@ -545,6 +549,7 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()), ); let mut tui = make_test_tui()?; + tui.pause_events(); app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; assert_eq!( @@ -647,6 +652,7 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R ) .await?; let mut tui = make_test_tui()?; + tui.pause_events(); app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; let first = app.chat_widget.thread_id().unwrap(); @@ -855,6 +861,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br ) .await?; let mut tui = make_test_tui()?; + tui.pause_events(); let new_session = app.new_agents_overview_session( &mut tui, &mut server, @@ -958,6 +965,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br &mut failed_server, Some(unused.cwd.clone().abs()), Some((manager.clone(), unused.clone())), + /*startup_draft*/ None, ) .await?; assert_eq!(app.chat_widget.thread_id(), Some(second)); diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index ee3cc2bc6dbb..816b3e60032c 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -61,6 +61,8 @@ pub(crate) enum StartupDraftInitialScreen { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum StartupDraftSessionAction { New, + /// Keep in-app creation alive while allowing draft edits until setup finishes. + NewFromCommandCenter, Resume, Fork, } @@ -111,25 +113,11 @@ impl StartupDraft { initialized_terminal.enhanced_keys_supported, initialized_terminal.stderr_guard, ); - let (app_event_tx, app_event_rx) = unbounded_channel(); - let bottom_pane = startup_draft_bottom_pane( - AppEventSender::new(app_event_tx), - tui.frame_requester(), - tui.enhanced_keys_supported(), - ); - let events = tui.event_stream(); + let pump = StartupDraftPump::new(&tui, initial_screen, session_action); let mut draft = Self { tui, terminal_restore_guard, - pump: StartupDraftPump { - header: startup_session_header(/*config*/ None), - bottom_pane, - events, - app_event_rx, - initial_screen, - session_action, - pending_paste_newline: None, - }, + pump, }; draft.pump.show_initial_screen(&mut draft.tui)?; Ok(draft) @@ -165,6 +153,43 @@ impl StartupDraft { } impl StartupDraftPump { + pub(crate) fn new( + tui: &Tui, + initial_screen: StartupDraftInitialScreen, + session_action: StartupDraftSessionAction, + ) -> Self { + let (app_event_tx, app_event_rx) = unbounded_channel(); + Self { + header: startup_session_header(/*config*/ None), + bottom_pane: startup_draft_bottom_pane( + AppEventSender::new(app_event_tx), + tui.frame_requester(), + tui.enhanced_keys_supported(), + ), + events: tui.event_stream(), + app_event_rx, + initial_screen, + session_action, + pending_paste_newline: None, + } + } + + /// Keep a provisional composer responsive when the caller has one to display. + pub(crate) async fn run_with_optional_draft( + draft: Option<&mut Self>, + tui: &mut Tui, + future: F, + ) -> Result + where + F: Future>, + E: From, + { + match draft { + Some(draft) => draft.run_until(tui, future).await?, + None => future.await, + } + } + /// Refresh the session header and safe editor shortcuts without enabling modal editing. pub(crate) fn apply_config(&mut self, config: &Config) { let local_settings = crate::local_settings::LocalSettings::from(config); @@ -264,6 +289,10 @@ impl StartupDraftPump { /// Preserve the editable draft, its cursor, and any pending large-paste placeholders. pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot { + self.take_draft() + } + + pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot { self.bottom_pane.flush_composer_paste_burst(); self.bottom_pane.composer_draft_snapshot() } @@ -346,16 +375,22 @@ impl StartupDraftPump { self.pending_paste_newline = Some((Instant::now(), "\n".to_string())); } } - handle_startup_draft_key(&mut self.bottom_pane, key).inspect_err(|error| { - if StartupCancelled::matches(error) - && let Err(clear_error) = tui.terminal.clear() - { - tracing::warn!( - error = %clear_error, - "failed to clear the cancelled startup composer" - ); + match handle_startup_draft_key(&mut self.bottom_pane, key) { + Err(error) if StartupCancelled::matches(&error) => { + // In-app setup owns a live creation request. Keep pumping input + // instead of abandoning it or blocking on cancellation cleanup. + if self.session_action != StartupDraftSessionAction::NewFromCommandCenter { + if let Err(clear_error) = tui.terminal.clear() { + tracing::warn!( + error = %clear_error, + "failed to clear the cancelled startup composer" + ); + } + return Err(error); + } } - })?; + result => result?, + } } TuiEvent::Paste(text) => { if self.initial_screen == StartupDraftInitialScreen::Composer { @@ -477,7 +512,7 @@ fn startup_draft_renderable<'a>( let mut renderable = FlexRenderable::new(); renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header)); let loading_message = match session_action { - StartupDraftSessionAction::New => None, + StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => None, StartupDraftSessionAction::Resume => Some(" Resuming session…"), StartupDraftSessionAction::Fork => Some(" Forking session…"), }; From 8452164c761c9225b2ee12c2bd1d48f818573704 Mon Sep 17 00:00:00 2001 From: Nick Felt Date: Thu, 17 Sep 2026 02:06:43 +0000 Subject: [PATCH 014/373] Mark finished empty Code Mode tool inventories as complete (#46081) ## Why Code Mode cells that finish without invoking tools omit completeness metadata, leaving a verified empty inventory indistinguishable from an unverified one. ## What changed - Retain completed, losslessly recorded cells even when they contain no tool calls. - Always include `executed_tool_calls` when setting `tool_calls_complete`, using `[]` for empty inventories or terminal waits with no new calls. - Preserve completeness across request retries while revalidating history and rejecting late calls or ambiguous output mappings. ## Testing Add regression coverage for empty tool discovery, errors, termination, feature gating, retries, and fresh-session requirements for waits. Update protocol assertions to require an explicit empty call list with completeness metadata. GitOrigin-RevId: 24affe47f46068b6559d04a0103958ae83a00b92 --- .../core/src/tools/executed_tool_calls.rs | 25 ++-- .../request_metadata_tests.rs | 84 ++++++++++++- codex-rs/core/tests/suite/code_mode.rs | 110 +++++++++++++++++- .../src/models/executed_tool_calls.rs | 2 + .../src/models/executed_tool_calls_tests.rs | 4 +- 5 files changed, 210 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/tools/executed_tool_calls.rs b/codex-rs/core/src/tools/executed_tool_calls.rs index 9e046f12ee5b..0e905272f082 100644 --- a/codex-rs/core/src/tools/executed_tool_calls.rs +++ b/codex-rs/core/src/tools/executed_tool_calls.rs @@ -107,7 +107,6 @@ struct RetainedToolCalls { enum CellCompletion { #[default] Unobserved, - Started, Recording, Incomplete, Complete, @@ -422,10 +421,8 @@ impl ExecutedToolCalls { } else { ExecutedToolCall::truncated(call.name, original_bytes, max_bytes) }; - cell.completion = if matches!( - cell.completion, - CellCompletion::Started | CellCompletion::Recording - ) && !duplicate_call_id + cell.completion = if cell.completion == CellCompletion::Recording + && !duplicate_call_id && !matches!( call.arguments(), ExecutedToolCallArguments::Truncated { .. } @@ -502,7 +499,7 @@ impl ExecutedToolCalls { if let Some(cell) = state.cells.get_mut(cell_id) { // Failed indexing must not make a known historical ID look fresh. cell.completion = if unique_cell && unique_origin && history_ids_indexed { - CellCompletion::Started + CellCompletion::Recording } else { CellCompletion::Incomplete }; @@ -515,10 +512,18 @@ impl ExecutedToolCalls { return; }; if let Some(cell) = state.cells.get_mut(cell_id) { - if cell.completion == CellCompletion::Recording { - cell.completion = CellCompletion::Complete; - } else if cell.completion != CellCompletion::Complete && cell.pending_calls.is_empty() { - state.cells.remove(cell_id); + match cell.completion { + CellCompletion::Recording => { + // The closed dispatch gate makes this lossless inventory final, even if empty. + cell.completion = CellCompletion::Complete; + } + CellCompletion::Unobserved | CellCompletion::Incomplete => { + // Drop unverified cells only after emitting any partial records. + if cell.pending_calls.is_empty() { + state.cells.remove(cell_id); + } + } + CellCompletion::Complete => {} } } } diff --git a/codex-rs/core/src/tools/executed_tool_calls/request_metadata_tests.rs b/codex-rs/core/src/tools/executed_tool_calls/request_metadata_tests.rs index ad5b8959d2ea..7acec7128970 100644 --- a/codex-rs/core/src/tools/executed_tool_calls/request_metadata_tests.rs +++ b/codex-rs/core/src/tools/executed_tool_calls/request_metadata_tests.rs @@ -631,7 +631,7 @@ fn executed_tool_call_recorder_bounds_retained_history_and_keeps_latest_calls() #[test] fn tool_call_completeness_requires_finished_lossless_recording() { - for scenario in ["empty", "unobserved", "late_call"] { + for scenario in ["unfinished", "unobserved", "late_call"] { let recorder = new_recorder(InitialHistory::Forked(Vec::new())); let cell_id = CellId::new(scenario.to_string()); if scenario == "unobserved" { @@ -642,7 +642,7 @@ fn tool_call_completeness_requires_finished_lossless_recording() { if scenario == "late_call" { recorder.finish_cell_recording(&cell_id); } - if scenario != "empty" { + if scenario != "unfinished" { recorder.record_nested_tool_call( cell_id.clone(), "nested-call".to_string(), @@ -650,7 +650,9 @@ fn tool_call_completeness_requires_finished_lossless_recording() { /*original_bytes*/ 2, ); } - recorder.finish_cell_recording(&cell_id); + if scenario != "unfinished" { + recorder.finish_cell_recording(&cell_id); + } let mut items = [exec_input("output"), exec_output("output")]; recorder.attach_pending_to_prompt(&mut items, &mut HashMap::new()); @@ -664,6 +666,82 @@ fn tool_call_completeness_requires_finished_lossless_recording() { } } +#[test] +fn finished_empty_tool_inventory_survives_request_retries() { + let recorder = new_recorder(InitialHistory::New); + let cell_id = CellId::new("empty-cell".to_string()); + recorder.start_cell(&cell_id, "output"); + recorder.finish_cell_recording(&cell_id); + let mut retry_cache = HashMap::new(); + for _ in 0..2 { + let mut items = [exec_input("output"), exec_output("output")]; + assert!(recorder.attach_pending_to_prompt(&mut items, &mut retry_cache)); + assert!(!has_direct_call_metadata(&items[1])); + assert_eq!( + serde_json::to_value(items[1].executed_tool_call_metadata()).unwrap(), + json!({ + "executed_tool_calls": [], + "tool_calls_complete": true, + "cell_id": "output", + }), + ); + } +} + +#[test] +fn empty_inventory_revalidates_history_on_retry() { + for scenario in ["wrong_input", "duplicate_output", "late_call"] { + let recorder = new_recorder(InitialHistory::New); + let cell = CellId::new("empty-cell".to_string()); + recorder.start_cell(&cell, "exec"); + recorder.finish_cell_recording(&cell); + let mut retry_cache = HashMap::new(); + let mut prompt = vec![exec_input("exec"), exec_output("exec")]; + recorder.attach_to_prompt(&mut prompt, &mut retry_cache); + assert_eq!(tool_calls_complete(&prompt[1]), Some(true)); + match scenario { + "wrong_input" => prompt[0] = wait_input("exec", &cell), + "duplicate_output" => prompt.push(prompt[1].clone()), + "late_call" => { + record_nested_call(&recorder, &cell, "late"); + } + _ => unreachable!(), + } + recorder.attach_to_prompt(&mut prompt, &mut retry_cache); + assert_eq!(tool_calls_complete(&prompt[1]), None, "{scenario}"); + } +} + +#[test] +fn empty_inventory_wait_requires_a_fresh_session() { + for fresh in [true, false] { + let history = if fresh { + InitialHistory::New + } else { + InitialHistory::Forked(Vec::new()) + }; + let recorder = new_recorder(history); + let cell = CellId::new("empty-cell".to_string()); + recorder.start_cell(&cell, "exec"); + let mut prompt = vec![exec_input("exec"), exec_output("exec")]; + let mut retry_cache = HashMap::new(); + recorder.attach_to_prompt(&mut prompt, &mut retry_cache); + assert!(prompt[1].executed_tool_call_metadata().is_none()); + recorder.register_cell(&cell, "wait"); + recorder.finish_cell_recording(&cell); + prompt.extend([wait_input("wait", &cell), output("wait")]); + recorder.attach_to_prompt(&mut prompt, &mut retry_cache); + assert!(!has_direct_call_metadata(&prompt[3])); + assert_eq!(tool_calls_complete(&prompt[3]), fresh.then_some(true)); + if fresh { + assert_eq!( + serde_json::to_value(prompt[3].executed_tool_call_metadata()).unwrap(), + json!({"cell_id": "exec", "executed_tool_calls": [], "tool_calls_complete": true}), + ); + } + } +} + #[test] fn tool_call_completeness_survives_waits_without_changing_deltas() { let metadata = json!({ "provider": { "ids": ["CLATE"] } }); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 04494892ed25..ea162cbda724 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -1217,6 +1217,111 @@ async fn mcp_code_mode_exclusion_does_not_change_direct_mode_tool_exposure() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(true; "enabled")] +#[test_case(false; "disabled")] +async fn code_mode_finished_discovery_has_empty_tool_inventory( + metadata_enabled: bool, +) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let (_test, follow_up) = run_code_mode_turn_with_config( + &server, + "Discover tools without calling any", + r#"text(ALL_TOOLS.filter(({ name }) => name === "test_sync_tool").map(({ name }) => name));"#, + move |config| { + if !metadata_enabled { + config.features.disable(Feature::ExecutedToolCallMetadata).unwrap(); + } + }, + ) + .await?; + let request = follow_up.single_request(); + let output = request.custom_tool_call_output("call-1"); + let (body, success) = custom_tool_output_body_and_success(&request, "call-1"); + assert_ne!(success, Some(false), "Code Mode failed: {body}"); + assert_eq!( + serde_json::from_str::(&body)?, + serde_json::json!(["test_sync_tool"]) + ); + let metadata = &output["internal_chat_message_metadata_passthrough"]; + let calls = serde_json::json!([]); + let complete = Value::Bool(true); + let cell_id = serde_json::json!("call-1"); + assert_eq!( + ( + metadata.get("executed_tool_calls"), + metadata.get("tool_calls_complete"), + metadata.get("cell_id"), + ), + ( + metadata_enabled.then_some(&calls), + metadata_enabled.then_some(&complete), + metadata_enabled.then_some(&cell_id), + ), + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_empty_error_has_complete_tool_inventory() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let (_test, follow_up) = run_code_mode_turn_with_config( + &server, + "Fail without calling a tool", + r#"throw new Error("empty-cell-error");"#, + |_| {}, + ) + .await?; + let request = follow_up.single_request(); + let (body, _) = custom_tool_output_body_and_success(&request, "call-1"); + assert!(body.contains("Error: empty-cell-error")); + let output = request.custom_tool_call_output("call-1"); + let metadata = &output["internal_chat_message_metadata_passthrough"]; + assert_eq!(metadata["cell_id"], "call-1"); + assert_eq!(metadata["executed_tool_calls"], serde_json::json!([])); + assert_eq!(metadata["tool_calls_complete"], true); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_terminated_empty_cell_has_complete_tool_inventory() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let (test, follow_up) = run_code_mode_turn_with_config( + &server, + "Yield without calling a tool", + r#"yield_control(); await new Promise(() => {});"#, + |_| {}, + ) + .await?; + let initial = follow_up.single_request(); + let output = initial.custom_tool_call_output("call-1"); + assert!( + output["internal_chat_message_metadata_passthrough"] + .get("tool_calls_complete") + .is_none() + ); + let items = custom_tool_output_items(&initial, "call-1"); + let cell_id = extract_running_cell_id(text_item(&items, /*index*/ 0)); + let wait = responses::mount_function_call_agent_response( + &server, + "call-2", + &serde_json::json!({"cell_id": cell_id, "terminate": true}).to_string(), + "wait", + ) + .await; + test.submit_turn("Terminate the empty cell").await?; + let request = wait.completion.single_request(); + let output = request.function_call_output("call-2"); + let metadata = &output["internal_chat_message_metadata_passthrough"]; + assert_eq!(metadata["cell_id"], "call-1"); + assert_eq!(metadata["executed_tool_calls"], serde_json::json!([])); + assert_eq!(metadata["tool_calls_complete"], true); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(false, false, false; "disabled")] #[test_case(true, false, false; "completed")] @@ -1549,7 +1654,10 @@ async fn code_mode_wait_id_stays_known_after_compaction( metadata.get("tool_calls_complete").and_then(Value::as_bool), expected_complete, ); - assert!(metadata.get("executed_tool_calls").is_none()); + assert_eq!( + metadata.get("executed_tool_calls").cloned(), + expected_complete.map(|_| serde_json::json!([])), + ); test.codex.shutdown_and_wait().await?; Ok(()) } diff --git a/codex-rs/protocol/src/models/executed_tool_calls.rs b/codex-rs/protocol/src/models/executed_tool_calls.rs index 85f3dbe35b02..227042c5961c 100644 --- a/codex-rs/protocol/src/models/executed_tool_calls.rs +++ b/codex-rs/protocol/src/models/executed_tool_calls.rs @@ -464,8 +464,10 @@ impl ResponseItem { } /// Marks a host-owned direct invocation or Code Mode cell's call inventory as complete. + /// Always includes this output's call list, which can be an empty delta for a terminal wait. pub fn mark_tool_calls_complete(&mut self) { if let Some(metadata) = self.ensure_tool_call_metadata() { + metadata.executed_tool_calls.get_or_insert_default(); metadata.tool_calls_complete = Some(true); } } diff --git a/codex-rs/protocol/src/models/executed_tool_calls_tests.rs b/codex-rs/protocol/src/models/executed_tool_calls_tests.rs index 248780b3ece6..5747aabab1eb 100644 --- a/codex-rs/protocol/src/models/executed_tool_calls_tests.rs +++ b/codex-rs/protocol/src/models/executed_tool_calls_tests.rs @@ -396,7 +396,9 @@ fn tool_call_completeness_is_host_only_and_fail_closed() -> Result<()> { bound_executed_tool_calls_for_prompt(std::slice::from_mut(&mut item)); assert_eq!( serde_json::to_value(&item)?["internal_chat_message_metadata_passthrough"], - serde_json::json!({ "cell_id": "cell-1", "tool_calls_complete": true }), + serde_json::json!({ + "cell_id": "cell-1", "executed_tool_calls": [], "tool_calls_complete": true + }), ); item.append_executed_tool_calls(vec![call]); item.clear_executed_tool_calls(); From 787823cf957709b314276646024ec54e1761c089 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 02:39:01 +0000 Subject: [PATCH 015/373] Add `--no-daemon` to bypass the shared background server (#46088) ## What changed - Run with `--no-daemon` without starting or probing the shared server, even when it is already running. Preserve the flag through `resume` and `fork`, and honor it for session archive commands. - Reject combinations with `--remote`, `codex agents`, and `codex queue`, which require a server connection. Point users to `codex --no-daemon` when the agents overview cannot start its shared server. - Centralize daemon eligibility checks and exclude launches using `--profile` or `CODEX_EXEC_SERVER_URL` from implicit daemon reuse. ## Testing Add coverage for flag propagation, incompatible command combinations, and daemon eligibility. Add a PTY test verifying that `--no-daemon` starts the TUI without creating daemon state or connecting to an existing control socket. GitOrigin-RevId: acc6a7cb339df6433bb8273c796194cdceda37c1 --- codex-rs/cli/src/main.rs | 37 ++++++++- codex-rs/cli/tests/features.rs | 32 ++++++++ codex-rs/tui/src/cli.rs | 4 + codex-rs/tui/src/daemon_startup.rs | 53 +++++++++++++ codex-rs/tui/src/daemon_startup_tests.rs | 79 ++++++++++++++++++++ codex-rs/tui/src/lib.rs | 54 +------------ codex-rs/tui/src/session_archive_commands.rs | 11 ++- codex-rs/tui/src/session_queue_commands.rs | 5 ++ codex-rs/tui/src/startup_orchestration.rs | 23 +++--- codex-rs/tui/tests/suite/focus_palette.rs | 40 +++++++++- 10 files changed, 271 insertions(+), 67 deletions(-) create mode 100644 codex-rs/tui/src/daemon_startup.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 339ab945734d..d474558eb85e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -337,6 +337,9 @@ struct DebugTraceReduceCommand { #[derive(Debug, Parser)] struct AgentsCommand { + /// The agents overview requires a shared server; this option is rejected. + #[arg(long, hide = true)] + no_daemon: bool, #[clap(flatten)] remote: InteractiveRemoteOptions, @@ -1215,6 +1218,7 @@ async fn cli_main( if let Some(options) = agents_options { interactive.cwd = options.cwd.clone().or(interactive.cwd.take()); interactive.no_alt_screen |= options.no_alt_screen; + interactive.no_daemon |= options.no_daemon; } let root_strict_config = interactive.strict_config; interactive @@ -2797,6 +2801,18 @@ async fn run_interactive_tui( remote_auth_token_env: Option, arg0_paths: Arg0DispatchPaths, ) -> std::io::Result { + if interactive.no_daemon { + if interactive.agents_overview { + return Ok(AppExitInfo::fatal( + "--no-daemon cannot be used with codex agents. The agents overview requires a shared server. Use codex --no-daemon to work without it.", + )); + } + if remote.is_some() { + return Ok(AppExitInfo::fatal( + "--no-daemon cannot be used with --remote.", + )); + } + } if let Some(prompt) = interactive.prompt.take() { // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); @@ -2833,7 +2849,9 @@ async fn run_interactive_tui( .map_err(std::io::Error::other)?; codex_app_server_daemon::run(AppServerLifecycleCommand::Start) .await - .map_err(std::io::Error::other)?; + .map_err(|err| std::io::Error::other(format!( + "{err:#}\nThe agents overview requires a shared server. Use codex --no-daemon to work without it." + )))?; } let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) { @@ -3056,6 +3074,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) approval_policy, web_search, no_alt_screen, + no_daemon, prompt, mut config_overrides, .. @@ -3076,6 +3095,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) interactive.web_search = true; } interactive.no_alt_screen |= no_alt_screen; + interactive.no_daemon |= no_daemon; if strict_config { interactive.strict_config = true; } @@ -4266,6 +4286,21 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn resume_and_fork_preserve_no_daemon() { + for (command, finalize) in [ + ("resume", finalize_resume_from_args as fn(&[&str]) -> TuiCli), + ("fork", finalize_fork_from_args as fn(&[&str]) -> TuiCli), + ] { + for args in [ + ["codex", "--no-daemon", command, "--last"], + ["codex", command, "--last", "--no-daemon"], + ] { + assert!(finalize(&args).no_daemon); + } + } + } + #[test] fn resume_and_fork_preserve_no_alt_screen() { for (command, finalize) in [ diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 83cf9ce3250a..a3ec9bc09697 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -298,3 +298,35 @@ async fn features_list_honors_cloud_managed_feature_requirements() -> Result<()> Ok(()) } + +#[test] +fn no_daemon_rejects_agents_and_explicit_remote_targets() -> Result<()> { + for args in [ + "--no-daemon agents", + "agents --no-daemon", + "--no-daemon queue --thread example --message hello", + "--no-daemon --remote ws://localhost:9999 agents", + "--no-daemon --remote ws://localhost:9999", + "--no-daemon --remote ws://localhost:9999 archive example", + "--no-daemon --remote ws://localhost:9999 queue --thread example --message hello", + "--remote ws://localhost:9999 resume --no-daemon --last", + "--no-daemon fork --remote ws://localhost:9999 session-name", + ] { + let args = args.split_whitespace().collect::>(); + let home = TempDir::new()?; + let expected = if args.contains(&"agents") { + "--no-daemon cannot be used with codex agents." + } else if args.contains(&"queue") && !args.contains(&"--remote") { + "--no-daemon cannot be used with codex queue." + } else { + "--no-daemon cannot be used with --remote." + }; + codex_command(home.path())? + .args(args) + .assert() + .failure() + .stderr(contains(expected)); + assert!(!home.path().join("app-server-daemon").exists()); + } + Ok(()) +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index c6379e59aadc..cfa2d862332b 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -80,6 +80,10 @@ pub struct Cli { #[arg(long = "no-alt-screen", default_value_t = false)] pub no_alt_screen: bool, + /// Run without the shared background server, even if it is already running. + #[arg(long)] + pub no_daemon: bool, + #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/daemon_startup.rs b/codex-rs/tui/src/daemon_startup.rs new file mode 100644 index 000000000000..20a189cc0ec7 --- /dev/null +++ b/codex-rs/tui/src/daemon_startup.rs @@ -0,0 +1,53 @@ +//! Local daemon discovery policy. Explicit embedded launches never probe the shared server. + +use super::*; + +pub(super) fn exclusion( + cli: &Cli, + cli_kv_overrides: &[(String, toml::Value)], + loader_overrides: &LoaderOverrides, + workload_identity_selected: bool, + exec_server_url: Option<&std::ffi::OsStr>, +) -> Option<&'static str> { + if cli.no_daemon { + Some("--no-daemon") + } else if cli.shared.worktree { + Some("--worktree") + } else if cli.oss { + Some("--oss") + } else if workload_identity_selected { + Some("workload identity") + } else if exec_server_url.is_some() { + Some("executor selection (CODEX_EXEC_SERVER_URL)") + } else if cli.agents_overview { + None + } else if cli.config_profile_v2.is_some() { + Some("--profile") + } else { + config_exclusion( + cli_kv_overrides, + loader_overrides, + cli.strict_config, + cli.bypass_hook_trust, + ) + } +} + +pub(super) fn config_exclusion( + cli_kv_overrides: &[(String, toml::Value)], + loader_overrides: &LoaderOverrides, + strict_config: bool, + bypass_hook_trust: bool, +) -> Option<&'static str> { + if !cli_kv_overrides.is_empty() { + Some("command-line configuration overrides (-c, --enable, --disable, or --search)") + } else if !loader_overrides_are_default(loader_overrides) { + Some("custom configuration loader") + } else if strict_config { + Some("--strict-config") + } else if bypass_hook_trust { + Some("--dangerously-bypass-hook-trust") + } else { + None + } +} diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 01bd8bc3cf18..49725cbb92c4 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -83,3 +83,82 @@ async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre:: } Ok(()) } + +#[test] +fn daemon_eligibility_preserves_launch_options_and_explains_exclusions() { + use clap::Parser; + for (args, expected) in [ + ("--no-daemon", Some("--no-daemon")), + ("--worktree", Some("--worktree")), + ("--oss", Some("--oss")), + ("--profile test", Some("--profile")), + ("--strict-config", Some("--strict-config")), + ( + "--dangerously-bypass-hook-trust", + Some("--dangerously-bypass-hook-trust"), + ), + ( + "-m test --cd /tmp -i image.png -a never -s workspace-write --add-dir /tmp --no-alt-screen hello", + None, + ), + ] { + let cli = Cli::parse_from(std::iter::once("codex").chain(args.split_whitespace())); + assert_eq!( + daemon_startup::exclusion( + &cli, + &[], + &LoaderOverrides::default(), + /*workload_identity_selected*/ false, + /*exec_server_url*/ None + ), + expected + ); + } + let mut cli = Cli::parse_from(["codex"]); + let overrides = vec![("web_search".into(), toml::Value::String("live".into()))]; + let loader = LoaderOverrides { + ignore_user_config: true, + ..Default::default() + }; + for (kv, loader, workload, executor, expected) in [ + ( + &overrides[..], + LoaderOverrides::default(), + false, + None, + "command-line configuration overrides (-c, --enable, --disable, or --search)", + ), + (&[][..], loader, false, None, "custom configuration loader"), + ( + &[][..], + LoaderOverrides::default(), + true, + None, + "workload identity", + ), + ( + &[][..], + LoaderOverrides::default(), + false, + Some(std::ffi::OsStr::new("executor")), + "executor selection (CODEX_EXEC_SERVER_URL)", + ), + ] { + assert_eq!( + daemon_startup::exclusion(&cli, kv, &loader, workload, executor), + Some(expected) + ); + } + cli.agents_overview = true; + cli.strict_config = true; + assert_eq!( + daemon_startup::exclusion( + &cli, + &overrides, + &LoaderOverrides::default(), + /*workload_identity_selected*/ false, + /*exec_server_url*/ None + ), + None + ); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b0cc9b549484..7c00ce4c503b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -987,19 +987,6 @@ fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool { loader_overrides_are_default } -fn can_reuse_implicit_local_daemon( - cli_kv_overrides: &[(String, toml::Value)], - loader_overrides: &LoaderOverrides, - strict_config: bool, - has_non_replayable_launch_overrides: bool, -) -> bool { - // A reused daemon cannot adopt this invocation's full launch config state. - cli_kv_overrides.is_empty() - && loader_overrides_are_default(loader_overrides) - && !strict_config - && !has_non_replayable_launch_overrides -} - /// Restore terminal modes before a fatal startup exit bypasses destructor cleanup. fn restore_terminal_before_fatal_exit() { if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { @@ -2175,6 +2162,8 @@ fn should_show_bedrock_setup_wizard( .is_login_method_allowed(ForcedLoginMethod::Api) } +mod daemon_startup; + #[cfg(test)] #[path = "daemon_startup_tests.rs"] mod daemon_startup_tests; @@ -3020,45 +3009,6 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } - #[test] - fn can_reuse_implicit_local_daemon_requires_default_launch_config() -> color_eyre::Result<()> { - let mut loader_overrides = LoaderOverrides::default(); - let cli_kv_overrides = vec![("web_search".to_string(), toml::Value::String("live".into()))]; - - assert!(can_reuse_implicit_local_daemon( - &[], - &LoaderOverrides::default(), - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ false, - )); - assert!(!can_reuse_implicit_local_daemon( - &cli_kv_overrides, - &LoaderOverrides::default(), - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ false, - )); - loader_overrides.ignore_user_config = true; - assert!(!can_reuse_implicit_local_daemon( - &[], - &loader_overrides, - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ false, - )); - assert!(!can_reuse_implicit_local_daemon( - &[], - &LoaderOverrides::default(), - /*strict_config*/ true, - /*has_non_replayable_launch_overrides*/ false, - )); - assert!(!can_reuse_implicit_local_daemon( - &[], - &LoaderOverrides::default(), - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ true, - )); - Ok(()) - } - #[test] fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { let target = AppServerTarget::LocalDaemon { diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 69f3f4a7d54b..917a5e42f750 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -228,6 +228,9 @@ pub(super) async fn start_app_server_for_session_command( arg0_paths, explicit_remote_endpoint, } = options; + if cli.no_daemon && explicit_remote_endpoint.is_some() { + return Err(eyre!("--no-daemon cannot be used with --remote.")); + } let loader_overrides = LoaderOverrides::default(); let strict_config = cli.strict_config; let raw_overrides = cli.config_overrides.raw_overrides.clone(); @@ -245,13 +248,15 @@ pub(super) async fn start_app_server_for_session_command( } let workload_identity_selected = codex_login::is_workload_identity_selected(); - let reuse_implicit_local_daemon = !workload_identity_selected - && super::can_reuse_implicit_local_daemon( + let reuse_implicit_local_daemon = !cli.no_daemon + && !workload_identity_selected + && super::daemon_startup::config_exclusion( &cli_kv_overrides, &launch_loader_overrides, strict_config, cli.bypass_hook_trust, - ); + ) + .is_none(); let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { super::maybe_probe_default_daemon_socket(codex_home.as_path()).await } else { diff --git a/codex-rs/tui/src/session_queue_commands.rs b/codex-rs/tui/src/session_queue_commands.rs index 485c8077a518..9752a4281737 100644 --- a/codex-rs/tui/src/session_queue_commands.rs +++ b/codex-rs/tui/src/session_queue_commands.rs @@ -29,6 +29,11 @@ pub async fn run_session_queue_command( message: String, options: SessionArchiveCommandOptions, ) -> Result { + if options.cli.no_daemon && options.explicit_remote_endpoint.is_none() { + return Err(eyre!( + "--no-daemon cannot be used with codex queue. Queuing must discover the shared server to avoid writing through a separate server." + )); + } let codex_home = find_codex_home().wrap_err("failed to find Codex home")?; let explicit_remote = options.explicit_remote_endpoint.is_some(); let mut app_server = diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 28ecb470469c..82e9eba18ddf 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -13,6 +13,11 @@ pub(super) async fn run_main_inner( loader_overrides: LoaderOverrides, explicit_remote_endpoint: Option, ) -> std::io::Result { + if cli.no_daemon && explicit_remote_endpoint.is_some() { + return Err(std::io::Error::other( + "--no-daemon cannot be used with --remote.", + )); + } let strict_config = cli.strict_config; if cli.shared.worktree { if explicit_remote_endpoint.is_some() { @@ -150,16 +155,14 @@ pub(super) async fn run_main_inner( .await; } - let reuse_implicit_local_daemon = !cli.shared.worktree - && !cli.oss - && !workload_identity_selected - && (cli.agents_overview - || can_reuse_implicit_local_daemon( - &cli_kv_overrides, - &launch_loader_overrides, - strict_config, - cli.bypass_hook_trust, - )); + let daemon_exclusion = daemon_startup::exclusion( + &cli, + &cli_kv_overrides, + &launch_loader_overrides, + workload_identity_selected, + std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(), + ); + let reuse_implicit_local_daemon = daemon_exclusion.is_none(); let search_only_config_override = !workload_identity_selected && cli.web_search && startup_preflight::has_only_search_config_override(&cli_kv_overrides) diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs index a29e3b50998f..c65b03932500 100644 --- a/codex-rs/tui/tests/suite/focus_palette.rs +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -14,7 +14,6 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; use anyhow::ensure; -#[cfg(target_os = "macos")] use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -396,3 +395,42 @@ pub(super) fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<( ) .context("write focus-test API-key authentication") } + +#[test] +fn no_daemon_skips_startup_and_discovery() -> Result<()> { + for running in [false, true] { + let workspace = tempfile::tempdir()?; + let home = tempfile::tempdir()?; + write_test_config(home.path(), workspace.path())?; + let socket_path = codex_app_server_client::app_server_control_socket_path(home.path())?; + std::fs::create_dir_all(socket_path.as_path().parent().unwrap())?; + let listener = if running { + let listener = std::os::unix::net::UnixListener::bind(socket_path.as_path())?; + listener.set_nonblocking(true)?; + Some(listener) + } else { + None + }; + let mut terminal = PtyCodex::start(workspace.path(), home, &["--no-daemon"])?; + terminal.wait_for_startup()?; + terminal.write_input(b"/status")?; + terminal.wait_for_screen("show current session configuration")?; + terminal.read_output(Duration::from_millis(/*millis*/ 200))?; + terminal.write_input(b"\r")?; + terminal.wait_for_screen("Model:")?; + ensure!( + !terminal + ._codex_home + .path() + .join("app-server-daemon") + .exists() + ); + if let Some(listener) = listener { + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + } + Ok(()) +} From f3da3861c557b4813d9840177a45940a465f9efe Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Thu, 17 Sep 2026 03:19:05 +0000 Subject: [PATCH 016/373] Add a one-time composer starfield for new Astra tasks (#46096) ## What changed Show a brief starfield in the empty composer when a new task starts with Astra or the user selects Astra through `/model` before entering ordinary prompt content. - Respect `tui.animations`, `tui.whimsy`, and true-color support; protect the placeholder and cursor and fade out by 15 seconds. - Preserve eligibility through recognized slash commands, but permanently dismiss it after ordinary draft content, attachments, or work. Carry draft eligibility through startup and disconnected editing. - Exclude resumed and forked tasks, and verify model-picker changes against the originating task and effective model when applied. - Hide the effect during overlays or focus loss without restarting its deadline or scheduling hidden animation frames. ## Testing Add regression tests and snapshots for task eligibility, model-picker transitions, draft and paste handling, history search, overlays, submissions, rendering protection, and animation expiry. GitOrigin-RevId: 832705543e9cae1d561125ece853a16c4eefe627 --- codex-rs/tui/src/app.rs | 4 + codex-rs/tui/src/app/agents_overview.rs | 9 +- codex-rs/tui/src/app/event_dispatch.rs | 16 + codex-rs/tui/src/app/input.rs | 1 + codex-rs/tui/src/app/session_lifecycle.rs | 8 +- codex-rs/tui/src/app/tests.rs | 2 + .../tests/background_task_defaults_tests.rs | 65 + .../tui/src/app/tests/fresh_sparkle_tests.rs | 735 +++++++++++ ...reation_is_fresh_but_returning_is_not.snap | 10 + ...application_after_an_automatic_update.snap | 12 + ...shows_stars_only_on_an_untouched_task.snap | 11 + codex-rs/tui/src/app/thread_routing.rs | 2 +- codex-rs/tui/src/app_event.rs | 9 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 238 ++-- .../chat_composer/history_search.rs | 37 +- .../bottom_pane/chat_composer/inline_input.rs | 4 +- .../bottom_pane/chat_composer/paste_input.rs | 118 +- .../bottom_pane/chat_composer/reconnect.rs | 10 +- ...aits_for_the_pending_command_to_leave.snap | 16 + ...ars_only_before_the_original_deadline.snap | 37 + ...kle_edits_are_tracked_between_renders.snap | 27 + ...tion_update_sparkles_before_rendering.snap | 15 + ...rkle_command_without_reclassifying_it.snap | 37 + ...ash_restores_sparkle_command_tracking.snap | 27 + ...les_without_restarting_their_deadline.snap | 53 + ...kle_but_literal_question_marks_do_not.snap | 35 + ..._dots_and_finishes_by_fifteen_seconds.snap | 19 + ...d_protects_the_placeholder_and_cursor.snap | 25 + .../src/bottom_pane/chat_composer/sparkle.rs | 426 +++++++ .../chat_composer/sparkle_field.rs | 70 ++ .../chat_composer/sparkle_tests.rs | 1073 +++++++++++++++++ .../bottom_pane/chat_composer/vim_history.rs | 18 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/input_restore.rs | 6 + .../tui/src/chatwidget/input_submission.rs | 1 + codex-rs/tui/src/chatwidget/model_popups.rs | 27 +- codex-rs/tui/src/chatwidget/replay.rs | 3 + codex-rs/tui/src/chatwidget/review_popups.rs | 1 + codex-rs/tui/src/chatwidget/session_flow.rs | 40 + .../src/chatwidget/session_model_selection.rs | 49 +- codex-rs/tui/src/chatwidget/settings.rs | 2 + codex-rs/tui/src/chatwidget/tests.rs | 2 + .../tests/session_model_selection_tests.rs | 50 + .../tests/sparkle_submission_tests.rs | 77 ++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 1 + 45 files changed, 3251 insertions(+), 178 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__review_regression_agents_overview_creation_is_fresh_but_returning_is_not.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__astra_picker_confirms_the_model_at_application_after_an_automatic_update.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__session_only_astra_picker_shows_stars_only_on_an_untouched_task.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__canceled_history_search_restores_visible_stars_only_before_the_original_deadline.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__disconnected_sparkle_edits_are_tracked_between_renders.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_acceptance_and_cancellation_update_sparkles_before_rendering.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_preserves_a_held_sparkle_command_without_reclassifying_it.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__review_regression_canceling_a_held_slash_restores_sparkle_command_tracking.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_fades_without_adding_dots_and_finishes_by_fifteen_seconds.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_matches_the_original_starfield_and_protects_the_placeholder_and_cursor.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/sparkle_field.rs create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 314fecc3a8dd..cb86b5ccd4c5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -897,6 +897,8 @@ impl App { match &event { TuiEvent::FocusLost => { + self.chat_widget + .set_sparkle_terminal_focus(/*focused*/ false); let now = Instant::now(); let thread_id = self.current_displayed_thread_id(); @@ -1011,6 +1013,8 @@ impl App { fn render_chat_widget_frame(&mut self, tui: &mut tui::Tui, screen_size: Size) -> Result { self.sync_thread_title_progress(); + self.chat_widget + .set_sparkle_terminal_focus(tui.is_terminal_focused()); let dashboard_visible = self .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 65d8442cd9d6..cc8098bb607b 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1,5 +1,6 @@ //! Daemon-wide overview of recent and locally retained sessions and their subagents. //! Tasks owned by another app server open as frozen, read-only history snapshots. +//! Only the immediate attachment of a dashboard-created task is treated as fresh. #[path = "agents_overview_new.rs"] mod new; @@ -14,6 +15,7 @@ mod loading; use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewView; +use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_event::AgentsOverviewThreadRefresh; use crate::bottom_pane::SelectionDescriptionLayout; @@ -511,6 +513,11 @@ impl App { } }; let mut history_notice = None; + let presentation = if started.is_some() { + ThreadAttachPresentation::Fresh + } else { + ThreadAttachPresentation::SessionLineage + }; let (resumed, read_only) = if let Some((_, started)) = started { (started, false) } else if !unloaded @@ -637,7 +644,7 @@ impl App { .replace_chat_widget_with_app_server_thread( tui, resumed, - super::session_lifecycle::ThreadAttachPresentation::SessionLineage, + presentation, /*initial_user_message*/ None, ) .await diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 4421e5a1def8..e61e76d43993 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1805,6 +1805,22 @@ impl App { .await; } } + AppEvent::AstraSelectedFromModelPicker { thread_id, model, action } => { + // Check and apply in the same event so a queued backend update cannot turn a + // no-op picker confirmation into a sparkle. + let should_offer = self.chat_widget.current_model() != model + && self.chat_widget.sparkle_thread_for_picker_action(&model) == Some(thread_id); + let control = Box::pin(self.handle_event( + tui, + app_server, + action.into_app_event(model.clone()), + )) + .await?; + if should_offer { + self.chat_widget.on_sparkle_model_selected_from_picker(&model); + } + return Ok(control); + } AppEvent::RealtimeWebrtcOfferCreated { thread_id, attempt_id, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 59718fcd73c1..7a6de1e70e64 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -469,6 +469,7 @@ impl App { // Esc so the active UI (e.g. status indicator, modals, popups) // handles it. if self.should_handle_backtrack_esc(key_event) { + self.chat_widget.prepare_composer_sparkle_key(key_event); self.handle_backtrack_esc_key(tui); } else if self.should_reject_side_backtrack_esc(key_event) { self.reject_side_backtrack_esc(); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 39d0c9cd30d1..df48cc7f0a38 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -18,6 +18,8 @@ use std::collections::HashSet; #[derive(Clone, Copy)] pub(super) enum ThreadAttachPresentation { + /// A primary thread created by thread/start, without inherited history. + Fresh, SessionLineage, } @@ -834,6 +836,7 @@ impl App { .set_queue_submissions_until_session_configured(/*queue*/ false); match result { Ok(started) => { + self.chat_widget.mark_fresh_task_for_sparkle(&started); let thread_id = started.session.thread_id; if started.task_tools_available { app_server.remember_task_tool_thread(thread_id); @@ -997,7 +1000,7 @@ impl App { .replace_chat_widget_with_app_server_thread( tui, started, - ThreadAttachPresentation::SessionLineage, + ThreadAttachPresentation::Fresh, initial_user_message, ) .await @@ -1056,6 +1059,9 @@ impl App { initial_user_message, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); + if matches!(presentation, ThreadAttachPresentation::Fresh) { + self.chat_widget.mark_fresh_task_for_sparkle(&started); + } self.chat_widget .set_task_mentions_enabled(started.task_tools_available); self.chat_widget diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 40d16d60252a..6496ea88f428 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -27,6 +27,8 @@ mod buffered_replay; mod connector_policy; #[path = "tests/disconnect_tests.rs"] mod disconnect; +#[path = "tests/fresh_sparkle_tests.rs"] +mod fresh_sparkle_tests; #[path = "tests/key_chords.rs"] mod key_chords; #[path = "tests/luna_reserve_recovery_tests.rs"] diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index e2ed7a59efe9..8c1ec55dfc2c 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -42,6 +42,71 @@ fn trust_launch_folder(app: &mut App) { app.config.active_project.trust_level = Some(codex_protocol::config_types::TrustLevel::Trusted); } +#[tokio::test] +async fn review_regression_agents_overview_creation_is_fresh_but_returning_is_not() -> Result<()> { + let render = |chat: &ChatWidget| { + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (230, 216, 255), + bg: (36, 27, 53), + }, + || render_bottom_popup(chat, /*width*/ 80), + ) + }; + let has_stars = |text: &str| text.chars().any(|ch| "⠁⠂⠄⠈⠐⠠⡀⢀".contains(ch)); + for model in ["gpt-6-astra", "gpt-5.5"] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + trust_launch_folder(&mut app); + app.cli_kv_overrides.extend([ + ("tui.animations".into(), TomlValue::Boolean(true)), + ("tui.whimsy".into(), TomlValue::Boolean(true)), + ]); + app.harness_overrides.model = Some(model.into()); + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = make_test_tui()?; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let original = app.chat_widget.thread_id().expect("new dashboard task"); + assert_eq!(app.chat_widget.current_model(), model); + let created = render(&app.chat_widget); + assert_eq!(has_stars(&created), model == "gpt-6-astra", "{model}"); + if model != "gpt-6-astra" { + app.chat_widget.set_model("gpt-6-astra"); + app.chat_widget + .on_sparkle_model_selected_from_picker("gpt-6-astra"); + assert!(has_stars(&render(&app.chat_widget))); + } + app.harness_overrides.model = Some("gpt-6-astra".into()); + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_ne!(app.chat_widget.thread_id(), Some(original)); + app.select_agents_overview_thread(&mut tui, &mut server, original) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(original)); + let returned = render(&app.chat_widget); + assert!(!has_stars(&returned)); + app.chat_widget + .on_sparkle_model_selected_from_picker("gpt-6-astra"); + assert!(!has_stars(&render(&app.chat_widget))); + if model == "gpt-6-astra" { + let before_footer = |output: &str| { + output + .lines() + .take_while(|line| !line.contains("gpt-6-astra")) + .collect::>() + .join("\n") + }; + insta::assert_snapshot!(format!( + "created from the dashboard:\n{}\nreturned to the existing task:\n{}", + before_footer(&created), + before_footer(&returned) + )); + } + server.shutdown().await?; + } + Ok(()) +} + #[tokio::test] async fn command_center_new_reads_server_defaults_for_actual_destination() -> Result<()> { let mut tui = make_test_tui()?; diff --git a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs new file mode 100644 index 000000000000..a5647c8a588d --- /dev/null +++ b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs @@ -0,0 +1,735 @@ +//! Covers confirmed new tasks, existing attachment paths, and the user model picker. + +use super::*; +use crate::app::session_lifecycle::ThreadAttachPresentation; +use crate::chatwidget::AstraModelPickerAction; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use codex_protocol::openai_models::ReasoningEffortPreset; +use pretty_assertions::assert_eq; + +fn started(model: &str) -> AppServerStartedThread { + let mut session = test_thread_session(ThreadId::new(), test_path_buf("/tmp/project")); + session.model = model.into(); + AppServerStartedThread { + session, + turns: Vec::new(), + blocks_direct_input: false, + task_tools_available: false, + } +} + +fn render_with_sparkle_palette(chat: &ChatWidget) -> String { + with_test_default_colors( + DefaultColors { + fg: (230, 216, 255), + bg: (36, 27, 53), + }, + || render_bottom_popup(chat, /*width*/ 80), + ) +} + +fn visible(chat: &ChatWidget) -> bool { + render_with_sparkle_palette(chat) + .chars() + .any(|ch| "⠁⠂⠄⠈⠐⠠⡀⢀".contains(ch)) +} + +fn type_into(chat: &mut ChatWidget, text: &str) { + for ch in text.chars() { + chat.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } +} + +async fn choose_model( + app: &mut App, + tui: &mut crate::tui::Tui, + server: &mut AppServerSession, + events: &mut tokio::sync::mpsc::UnboundedReceiver, + model: &str, + key: KeyCode, +) -> Result { + let mut preset = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + preset.model = model.into(); + preset.display_name = model.into(); + preset.show_in_picker = true; + preset.supported_reasoning_efforts.truncate(/*len*/ 1); + preset.default_reasoning_effort = preset.supported_reasoning_efforts[0].effort.clone(); + app.chat_widget.open_model_popup_with_presets(vec![preset]); + app.chat_widget + .handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)); + + let mut sparkle_offers = 0; + while let Ok(event) = events.try_recv() { + if matches!( + &event, + AppEvent::OpenReasoningPopup { .. } + | AppEvent::UpdateModel(_) + | AppEvent::SelectSessionModel { .. } + | AppEvent::AstraSelectedFromModelPicker { .. } + ) { + if let AppEvent::AstraSelectedFromModelPicker { thread_id, .. } = &event { + assert_eq!(Some(*thread_id), app.chat_widget.thread_id()); + } + let changes_model = matches!(&event, AppEvent::AstraSelectedFromModelPicker { model, .. } + if app.chat_widget.current_model() != model); + app.handle_event(tui, server, event).await?; + if changes_model && app.chat_widget.current_model() == model { + sparkle_offers += 1; + } + } + } + assert!(!app.chat_widget.has_active_view()); + Ok(sparkle_offers) +} + +#[tokio::test] +async fn only_a_confirmed_empty_new_task_shows_the_sparkle() -> Result<()> { + for scenario in [ + "fresh", + "empty_resume", + "fork", + "replay", + "initial_prompt", + "parent_owned", + ] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut thread = started("gpt-6-astra"); + let presentation = if scenario == "empty_resume" { + ThreadAttachPresentation::SessionLineage + } else { + ThreadAttachPresentation::Fresh + }; + if scenario == "fork" { + thread.session.forked_from_id = Some(ThreadId::new()); + } + if scenario == "replay" { + thread + .turns + .push(test_turn("existing", TurnStatus::Completed, Vec::new())); + } + if scenario == "parent_owned" { + thread.blocks_direct_input = true; + } + let initial_message = (scenario == "initial_prompt") + .then(|| create_initial_user_message(Some("hello".into()), Vec::new(), Vec::new())) + .flatten(); + app.replace_chat_widget_with_app_server_thread( + &mut tui, + thread, + presentation, + initial_message, + ) + .await?; + assert_eq!(visible(&app.chat_widget), scenario == "fresh", "{scenario}"); + app.chat_widget.set_model("gpt-5.5"); + app.chat_widget.set_model("gpt-6-astra"); + assert!( + !visible(&app.chat_widget), + "automatic model update: {scenario}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn disconnected_sparkle_input_routed_by_app_consumes_the_opportunity_without_drawing() +-> Result<()> { + for model in ["gpt-5.5", "gpt-6-astra"] { + for edited in [false, true] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started(model), + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + assert_eq!(visible(&app.chat_widget), model == "gpt-6-astra"); + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + assert!(app.begin_reconnect()); + if edited { + for code in [KeyCode::Char('?'), KeyCode::Backspace] { + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)), + ) + .await?; + } + } + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + app.chat_widget.set_footer_hint_override(/*items*/ None); + app.chat_widget.pause_unavailable_thread(); + app.chat_widget.set_model("gpt-6-astra"); + app.chat_widget + .on_sparkle_model_selected_from_picker("gpt-6-astra"); + assert_eq!( + visible(&app.chat_widget), + !edited, + "{model}, edited: {edited}" + ); + } + } + Ok(()) +} + +#[tokio::test] +async fn commands_can_precede_the_sparkle_but_inserted_or_typed_drafts_cannot() -> Result<()> { + for earlier_input in [ + "", + "commands", + "shortcut_help", + "mention", + "typed", + "paste", + "paste_question_mark", + "cancel_picker", + ] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + app.local_settings.tui.disable_paste_burst = Some(true); + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started("gpt-5.5"), + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + assert!(!visible(&app.chat_widget)); + match earlier_input { + "typed" => { + type_into(&mut app.chat_widget, "x"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + "paste" | "paste_question_mark" => { + let pasted = if earlier_input == "paste" { "x" } else { "?" }; + app.chat_widget.handle_paste(pasted.into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + "shortcut_help" => { + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 80) + .contains("customize shortcuts with /keymap") + ); + } + "commands" => { + for command in ["/status", "/pwd"] { + type_into(&mut app.chat_widget, command); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + } + } + "mention" => { + type_into(&mut app.chat_widget, "/mention"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + "" | "cancel_picker" => {} + _ => unreachable!(), + } + type_into(&mut app.chat_widget, "/model"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + if earlier_input == "cancel_picker" { + assert!(app.chat_widget.has_active_view()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget.set_model("gpt-6-astra"); + } else { + assert_eq!( + choose_model( + &mut app, + &mut tui, + &mut server, + &mut events, + "gpt-6-astra", + KeyCode::Enter, + ) + .await?, + 1 + ); + } + assert_eq!( + visible(&app.chat_widget), + matches!(earlier_input, "" | "commands" | "shortcut_help"), + "{earlier_input}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn overlays_shortcuts_and_key_chords_leave_the_main_sparkle_untouched() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started("gpt-6-astra"), + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + let keymap_config = toml::from_str("[global]\ntoggle_raw_output = [\"ctrl-x r\"]")?; + app.keymap = RuntimeKeymap::from_config(&keymap_config).expect("valid app chord"); + app.chat_widget + .apply_keymap_update(keymap_config, &app.keymap); + assert!(visible(&app.chat_widget)); + + let ctrl = |ch| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL); + for (event, pending, raw) in [ + (ctrl('x'), true, false), + (KeyCode::Char('r').into(), false, true), + (ctrl('x'), true, true), + (KeyCode::Esc.into(), false, true), + ] { + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(event)) + .await?; + assert_eq!( + ( + app.key_chord_matcher.is_pending(), + app.chat_widget.raw_output_mode(), + visible(&app.chat_widget) + ), + (pending, raw, true) + ); + } + + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(ctrl('t'))) + .await?; + assert!(app.overlay.is_some()); + for event in [ + TuiEvent::Key(KeyCode::Char('j').into()), + TuiEvent::Paste("overlay input".into()), + TuiEvent::Key(ctrl('t')), + ] { + app.handle_tui_event(&mut tui, &mut server, event).await?; + } + assert!(app.overlay.is_none()); + assert_eq!( + ( + app.chat_widget.composer_is_empty(), + visible(&app.chat_widget) + ), + (true, true) + ); + + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into())) + .await?; + assert!(!visible(&app.chat_widget)); + Ok(()) +} + +#[tokio::test] +async fn a_command_pending_on_fresh_astra_start_can_finish_before_the_sparkle() -> Result<()> { + for (command, visible_after) in [("/status", true), ("/mention", false)] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + app.chat_widget.local_settings.tui.animations = true; + app.chat_widget.local_settings.tui.whimsy = true; + app.pending_startup_thread_start = true; + app.chat_widget.handle_paste(command.into()); + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.handle_startup_thread_started(&mut server, Ok(started("gpt-6-astra"))) + .await?; + assert_eq!( + ( + app.chat_widget.composer_text_with_pending(), + visible(&app.chat_widget) + ), + (command.into(), false) + ); + + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert_eq!(visible(&app.chat_widget), visible_after, "{command}"); + } + Ok(()) +} + +#[tokio::test] +async fn only_confirmed_picker_model_changes_can_arm_the_sparkle() -> Result<()> { + for completion in [ + "esc", + "ctrl_c", + "same_model", + "automatic_while_open", + "pick_astra", + "old_task", + ] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + app.local_settings.tui.disable_paste_burst = Some(true); + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let thread = started("gpt-5.5"); + let original_id = thread.session.thread_id; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + thread, + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + assert!(!visible(&app.chat_widget)); + type_into(&mut app.chat_widget, "/model"); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(app.chat_widget.has_active_view()); + + match completion { + "esc" | "ctrl_c" => { + let key = if completion == "esc" { + KeyCode::Esc.into() + } else { + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) + }; + app.chat_widget.handle_key_event(key); + assert!(!app.chat_widget.has_active_view()); + app.chat_widget.set_model("gpt-6-astra"); + } + "same_model" => { + assert_eq!( + choose_model( + &mut app, + &mut tui, + &mut server, + &mut events, + "gpt-5.5", + KeyCode::Enter, + ) + .await?, + 0 + ); + app.chat_widget.set_model("gpt-6-astra"); + } + "automatic_while_open" => { + app.chat_widget.set_model("gpt-6-astra"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(!app.chat_widget.has_active_view()); + } + "pick_astra" => { + assert_eq!( + choose_model( + &mut app, + &mut tui, + &mut server, + &mut events, + "gpt-6-astra", + KeyCode::Enter, + ) + .await?, + 1 + ); + } + "old_task" => { + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started("gpt-5.5"), + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + app.chat_widget.set_model("gpt-6-astra"); + app.handle_event( + &mut tui, + &mut server, + AppEvent::AstraSelectedFromModelPicker { + thread_id: original_id, + model: "gpt-6-astra".into(), + action: AstraModelPickerAction::UpdateModel, + }, + ) + .await?; + } + _ => unreachable!(), + } + assert_eq!( + visible(&app.chat_widget), + completion == "pick_astra", + "{completion}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn astra_picker_confirms_the_model_at_application_after_an_automatic_update() -> Result<()> { + let mut snapshots = Vec::new(); + for (picker, key, switches_away) in [ + ("default no-op", KeyCode::Enter, false), + ("session no-op", KeyCode::Char('s'), false), + ("advanced no-op", KeyCode::Enter, false), + ("default queued no-op", KeyCode::Enter, false), + ("session queued no-op", KeyCode::Char('s'), false), + ("default changes model", KeyCode::Enter, true), + ("session changes model", KeyCode::Char('s'), true), + ("advanced changes model", KeyCode::Enter, true), + ] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.model = Some("gpt-5.5".into()); + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let thread = server.start_thread(&app.config).await?; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + thread, + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + if switches_away { + app.chat_widget.set_model("gpt-6-astra"); + } + assert!(!visible(&app.chat_widget)); + + let mut preset = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + preset.model = "gpt-6-astra".into(); + preset.default_reasoning_effort = if picker.starts_with("advanced") { + ReasoningEffortConfig::Ultra + } else { + ReasoningEffortConfig::Medium + }; + preset.supported_reasoning_efforts = vec![ + ReasoningEffortPreset { + effort: preset.default_reasoning_effort.clone(), + description: "Selected effort".into(), + }, + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Low, + description: "Low effort".into(), + }, + ]; + if picker.starts_with("advanced") { + app.chat_widget.open_advanced_reasoning_popup(preset); + } else { + app.chat_widget.open_reasoning_popup(preset); + } + assert!(app.chat_widget.has_active_view()); + if !picker.contains("queued") { + app.chat_widget.set_model(if switches_away { + "gpt-5.5" + } else { + "gpt-6-astra" + }); + assert!(app.chat_widget.has_active_view()); + } + app.chat_widget.handle_key_event(key.into()); + if picker.contains("queued") { + app.chat_widget.set_model("gpt-6-astra"); + } + while let Ok(event) = events.try_recv() { + if matches!( + &event, + AppEvent::UpdateModel(_) + | AppEvent::UpdateReasoningEffort(_) + | AppEvent::SelectSessionModel { .. } + | AppEvent::ApplyAdvancedReasoning { .. } + | AppEvent::AstraSelectedFromModelPicker { .. } + ) { + app.handle_event(&mut tui, &mut server, event).await?; + } + } + assert!(!app.chat_widget.has_active_view(), "{picker}"); + assert_eq!(app.chat_widget.current_model(), "gpt-6-astra", "{picker}"); + assert_eq!(visible(&app.chat_widget), switches_away, "{picker}"); + if picker.starts_with("default") && !picker.contains("queued") { + let rendered = render_with_sparkle_palette(&app.chat_widget); + let lines = rendered.lines().collect::>(); + let prompt = lines + .iter() + .position(|line| line.contains("Ask Codex to do anything")) + .expect("empty composer shows its placeholder"); + let composer = lines[prompt.saturating_sub(1)..=prompt + 1].join("\n"); + snapshots.push(format!("{picker}:\n{composer}")); + } + server.shutdown().await?; + } + insta::assert_snapshot!(snapshots.join("\n\n")); + Ok(()) +} + +#[tokio::test] +async fn session_only_astra_picker_shows_stars_only_on_an_untouched_task() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.model = Some("gpt-5.5".into()); + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + app.local_settings.tui.disable_paste_burst = Some(true); + let config_path = app.config.codex_home.join("config.toml"); + let defaults = "model = 'gpt-5.5'\nmodel_reasoning_effort = 'medium'\n"; + std::fs::write(&config_path, defaults)?; + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut snapshots = Vec::new(); + + for scenario in ["untouched", "typed then erased"] { + let thread = server.start_thread(&app.config).await?; + assert_eq!(thread.session.model, "gpt-5.5"); + app.replace_chat_widget_with_app_server_thread( + &mut tui, + thread, + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + assert!(!visible(&app.chat_widget)); + if scenario == "typed then erased" { + type_into(&mut app.chat_widget, "x"); + app.chat_widget.handle_key_event(KeyCode::Backspace.into()); + } + type_into(&mut app.chat_widget, "/model"); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(app.chat_widget.has_active_view()); + assert_eq!( + choose_model( + &mut app, + &mut tui, + &mut server, + &mut events, + "gpt-6-astra", + KeyCode::Char('s'), + ) + .await?, + 1 + ); + assert_eq!(app.chat_widget.current_model(), "gpt-6-astra"); + + let rendered = render_with_sparkle_palette(&app.chat_widget); + let lines = rendered.lines().collect::>(); + let prompt = lines + .iter() + .position(|line| line.contains("Ask Codex to do anything")) + .expect("empty composer shows its placeholder"); + let composer = lines[prompt.saturating_sub(1)..=prompt + 1].join("\n"); + assert_eq!( + composer.chars().any(|ch| "⠁⠂⠄⠈⠐⠠⡀⢀".contains(ch)), + scenario == "untouched" + ); + snapshots.push(format!("{scenario}:\n{composer}")); + assert_eq!(std::fs::read(&config_path)?, defaults.as_bytes()); + assert_eq!( + (&app.config.model, &app.config.model_reasoning_effort), + ( + &Some("gpt-5.5".into()), + &Some(ReasoningEffortConfig::Medium), + ) + ); + + type_into(&mut app.chat_widget, "x"); + app.chat_widget.handle_key_event(KeyCode::Backspace.into()); + assert_eq!( + choose_model( + &mut app, + &mut tui, + &mut server, + &mut events, + "gpt-6-astra", + KeyCode::Char('s'), + ) + .await?, + 0 + ); + assert!(!visible(&app.chat_widget)); + } + + insta::assert_snapshot!(snapshots.join("\n\n")); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn a_read_only_command_can_return_to_active_stars_but_mention_ends_them() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + app.local_settings.tui.animations = true; + app.local_settings.tui.whimsy = true; + app.local_settings.tui.disable_paste_burst = Some(true); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started("gpt-6-astra"), + ThreadAttachPresentation::Fresh, + /*initial_user_message*/ None, + ) + .await?; + assert!(visible(&app.chat_widget)); + type_into(&mut app.chat_widget, "/status"); + assert!(!visible(&app.chat_widget)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(visible(&app.chat_widget)); + type_into(&mut app.chat_widget, "/mention"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!visible(&app.chat_widget)); + Ok(()) +} + +#[tokio::test] +async fn early_input_and_real_work_consume_the_sparkle() -> Result<()> { + for scenario in ["untouched", "typed_then_erased", "paste", "turn", "review"] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + app.chat_widget.local_settings.tui.animations = true; + app.chat_widget.local_settings.tui.whimsy = true; + app.pending_startup_thread_start = true; + match scenario { + "typed_then_erased" => { + app.chat_widget.handle_paste("x".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + "paste" => app.chat_widget.handle_paste("draft".into()), + "untouched" | "turn" | "review" => {} + _ => unreachable!(), + } + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let thread = started("gpt-6-astra"); + let thread_id = thread.session.thread_id; + app.handle_startup_thread_started(&mut server, Ok(thread)) + .await?; + if scenario == "turn" { + app.handle_app_server_event( + &server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + turn_started_notification(thread_id, "work"), + )), + ) + .await; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.drain_active_thread_events(&mut tui).await?; + } else if scenario == "review" { + app.chat_widget.on_review_started(); + } + assert_eq!( + visible(&app.chat_widget), + scenario == "untouched", + "{scenario}" + ); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__review_regression_agents_overview_creation_is_fresh_but_returning_is_not.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__review_regression_agents_overview_creation_is_fresh_but_returning_is_not.snap new file mode 100644 index 000000000000..c249adfe8ae2 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__review_regression_agents_overview_creation_is_fresh_but_returning_is_not.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/background_task_defaults_tests.rs +expression: "format!(\"created from the dashboard:\\n{}\\nreturned to the existing task:\\n{}\",\nbefore_footer(&created), before_footer(&returned))" +--- +created from the dashboard: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ +returned to the existing task: +› Ask Codex to do anything diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__astra_picker_confirms_the_model_at_application_after_an_automatic_update.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__astra_picker_confirms_the_model_at_application_after_an_automatic_update.snap new file mode 100644 index 000000000000..243ad7ae65be --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__astra_picker_confirms_the_model_at_application_after_an_automatic_update.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/tests/fresh_sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +default no-op: +› Ask Codex to do anything + + +default changes model: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__session_only_astra_picker_shows_stars_only_on_an_untouched_task.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__session_only_astra_picker_shows_stars_only_on_an_untouched_task.snap new file mode 100644 index 000000000000..eaaa3418dce0 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__fresh_sparkle_tests__session_only_astra_picker_shows_stars_only_on_an_untouched_task.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/fresh_sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +untouched: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + +typed then erased: +› Ask Codex to do anything diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 93258432e873..9d719edbd60f 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1565,7 +1565,7 @@ impl App { self.chat_widget.set_token_info(/*info*/ None); } match presentation { - ThreadAttachPresentation::SessionLineage => { + ThreadAttachPresentation::Fresh | ThreadAttachPresentation::SessionLineage => { self.chat_widget.handle_thread_session(session); } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f29095ada9ea..e4529c4d653a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -51,6 +51,7 @@ use crate::app_server_session::AppServerStartedThread; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; +use crate::chatwidget::AstraModelPickerAction; use crate::chatwidget::ConnectorScopeGeneration; use crate::chatwidget::ThreadUsageOutcome; use crate::chatwidget::UserMessage; @@ -1118,6 +1119,14 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Apply a final Astra picker action and offer the flourish only if it changed the model on + /// its original task. Automatic model updates do not use this event. + AstraSelectedFromModelPicker { + thread_id: ThreadId, + model: String, + action: AstraModelPickerAction, + }, + /// Result of creating a TUI-owned WebRTC offer for an active thread. RealtimeWebrtcOfferCreated { thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2b86759a6735..9bfe443e2389 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4,7 +4,7 @@ //! completed slash commands to atomic elements, and handles Enter submission/newlines. //! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts //! from raw key streams, particularly on Windows. -//! The live voice strip renders after effort ignition. +//! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible. //! //! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab //! submit ordinary text without enabling shell mode. @@ -120,8 +120,9 @@ //! navigation slash commands remain available so users can leave or manage the view. Transcript //! exports also remain available, including an explicit destination filename. //! -//! During reconnection, `handle_disconnected_key` edits the draft directly without -//! popup dispatch or submission. Enter and Tab leave the draft intact until reconnection succeeds. +//! During reconnection, `handle_disconnected_key` edits the draft directly without popup dispatch, +//! composer shortcuts, or submission; `?` becomes literal input. Enter and Tab leave the draft +//! intact until reconnection succeeds. //! Collapsed pastes expand into editable text so the full draft can be copied before quitting. //! //! # Reasoning Effort Animations @@ -134,6 +135,36 @@ //! restoration clears any transition queued while replaying the saved session. Rendering advances //! active transitions through the frame requester until they finish. //! +//! # Astra Sparkle Eligibility +//! +//! A confirmed new task can offer one flourish when it starts with Astra or the user switches to +//! Astra through `/model` before entering ordinary prompt content. The draft tracks whether it is +//! untouched, contains a recognized slash command, or is permanently dismissed. An eligible slash +//! command keeps the opportunity while being typed, dispatched, or erased; a fresh Astra response +//! can arm the effect while the command is still pending. Stars stay hidden until the composer is +//! empty. Commands that insert draft content, ordinary typed, pasted, or restored drafts, +//! attachments, and real work dismiss the opportunity even if that content is later cleared. +//! Ordinary disconnected edits also dismiss it immediately, even when erased before rendering; +//! a recognized command typed and erased offline still preserves the opportunity. +//! Opening history search, typing or pasting its query, and canceling it with `Esc` or `Ctrl+C` +//! temporarily hide an active field without restarting its deadline. Any buffered keys are +//! integrated into the original draft without being classified again. A preview does not count +//! as a draft until the user accepts a nonempty match. +//! Toggling shortcut help on an empty composer also hides an active field until help closes, +//! without restarting its deadline, and leaves an unused opportunity intact. A literal `?` is +//! ordinary prompt content. +//! Startup draft snapshots carry this distinction so erased ordinary input does not become +//! untouched on handoff. +//! +//! Sparkle key and paste handling runs only for input routed to the composer; ordinary characters +//! are observed before the paste-burst buffer can hide the first edit. An edit that flushes and +//! erases a held slash also clears the tracked command. An empty main-composer `Esc` +//! can stop the active flourish. Once shown, it fades out by 15 seconds; command entry, popups, and +//! focus loss hide the field without restarting that deadline. Rendering runs after the textarea, +//! placeholder, effort ignition, and voice strip, and draws only in eligible blank cells without +//! overwriting the placeholder or normal cursor. Hidden frames do not schedule animation redraws; +//! motion settings, whimsy settings, and true-color support also gate the effect. +//! //! # Large Paste Placeholders //! //! Large pastes insert an element placeholder in the buffer and store the full text in @@ -201,8 +232,9 @@ //! - Unmodified Tab joins detected bursts, including short Unicode prefixes, before popup dispatch. //! Expired bursts are flushed first so manual Tab keeps its normal shortcut behavior. //! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called -//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a -//! normal typed character. +//! from UI ticks to integrate a pending burst via [`ChatComposer::apply_paste`] or insert a +//! held single character. Explicit terminal paste events use [`ChatComposer::handle_paste`] to +//! classify input first; buffered keys have already been classified. //! //! # Input Disabled Mode //! @@ -287,7 +319,6 @@ use super::skill_popup::SkillPopup; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandItem; use super::voice_strip::VoiceStrip; -use crate::bottom_pane::paste_burst::FlushResult; use crate::history_cell::sanitize_user_text; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; @@ -319,6 +350,7 @@ mod paste_input; mod popup_state; mod reconnect; mod slash_input; +mod sparkle; mod vim_history; mod vim_search; @@ -531,6 +563,7 @@ pub(crate) struct ChatComposer { footer: FooterState, has_focus: bool, frame_requester: Option, + sparkle: sparkle::Sparkle, effort_tier: Option, effort_animation_style: Option, effort_ignition: Option, @@ -605,6 +638,7 @@ pub(crate) struct ComposerDraftSnapshot { pub(crate) pending_pastes: Vec<(String, String)>, pub(crate) startup_local_history: Vec, pub(crate) last_composer_activity_at: Option, + pub(crate) sparkle_draft: sparkle::SparkleDraft, } const FOOTER_SPACING_HEIGHT: u16 = 0; @@ -699,6 +733,7 @@ impl ChatComposer { }, has_focus: has_input_focus, frame_requester: None, + sparkle: sparkle::Sparkle::default(), effort_tier: None, effort_animation_style: None, effort_ignition: None, @@ -1203,64 +1238,6 @@ impl ChatComposer { self.history.record_replayed_submission(entry); } - /// Integrate pasted text into the composer. - /// - /// Acts as the only place where paste text is integrated, both for: - /// - /// - Real/explicit paste events surfaced by the terminal, and - /// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers - /// and later flushes here. - /// - /// Behavior: - /// - /// - If history search is active, inserts nonempty text into its query and ignores empty pastes. - /// - If Vim search is active, inserts text into its query. - /// - Otherwise, if the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a - /// placeholder element (expanded on submit) and stores the full text in `pending_pastes`. - /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a - /// trailing space so the user can keep typing naturally. - /// - Otherwise, inserts the pasted text directly into the textarea. - /// - /// For composer edits, clears any paste-burst Enter suppression state so a real paste cannot - /// affect the next user Enter key, then syncs popup state. - pub fn handle_paste(&mut self, pasted: String) -> bool { - let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); - let pasted = sanitize_user_text(pasted.into()); - if self.history_search.is_some() { - if !pasted.is_empty() { - self.update_history_search_query(|query| query.push_str(&pasted)); - } - return true; - } - if let Some(query) = self.draft.textarea.vim_query_mut() { - query.editor.insert_str(&pasted); - return true; - } - let started_vim_edit = self.begin_direct_vim_edit(); - let char_count = pasted.chars().count(); - if char_count > LARGE_PASTE_CHAR_THRESHOLD { - let placeholder = self.next_large_paste_placeholder(char_count); - self.draft.textarea.insert_element(&placeholder); - self.draft - .pending_pastes - .push((placeholder, pasted.into_owned())); - } else if char_count > 1 - && self.image_paste_enabled() - && self.handle_paste_image_path(&pasted) - { - let cursor = self.draft.textarea.cursor(); - self.draft.textarea.insert_str_at(cursor, " "); - } else { - self.insert_str(&pasted); - } - self.draft.paste_burst.clear_after_explicit_paste(); - self.sync_popups(); - if started_vim_edit { - self.finish_vim_edit(); - } - true - } - pub fn handle_paste_image_path(&mut self, pasted: &str) -> bool { let Some(path_buf) = normalize_pasted_path(pasted) else { return false; @@ -1284,41 +1261,13 @@ impl ChatComposer { } } - /// Enable or disable paste-burst handling. - /// - /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic - /// is unwanted or has already been handled elsewhere. - /// - /// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it - /// cannot affect subsequent normal typing: - /// - /// - First, flush any held/buffered text immediately via - /// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`. - /// This preserves user input and routes it through the same integration path as explicit - /// pastes (large-paste placeholders, image-path detection, and popup sync). - /// - Then clear the burst timing and Enter-suppression window via - /// [`PasteBurst::clear_after_explicit_paste`]. - /// - /// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state - /// without emitting any buffered text, which can leave a non-empty buffer unable to flush - /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). - pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { - let was_disabled = self.draft.disable_paste_burst; - self.draft.disable_paste_burst = disabled; - if disabled && !was_disabled { - if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - self.draft.paste_burst.clear_after_explicit_paste(); - } - } - /// Replace the composer content with text from an external editor. /// Clears pending paste placeholders and keeps only attachments whose /// placeholder labels still appear in the new text. Image placeholders /// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of /// remote images). Cursor is placed at the end after rebuilding elements. pub(crate) fn apply_external_edit(&mut self, text: String) { + self.note_sparkle_replaced_text(&text); self.vim_history = VimHistory::default(); self.draft.pending_pastes.clear(); let (text, _) = self.imported_text_for_textarea(text, Vec::new()); @@ -1404,7 +1353,7 @@ impl ChatComposer { /// footer mode so the visible hints match the new editing surface. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } self.draft.textarea.enable_vim_search(); self.draft.textarea.set_vim_enabled(enabled); @@ -1550,6 +1499,9 @@ impl ChatComposer { } pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { + if !self.sparkle.history_preview && !urls.is_empty() { + self.dismiss_sparkle(); + } self.attachments .set_remote_image_urls(urls, &mut self.draft.textarea); self.sync_popups(); @@ -1595,6 +1547,13 @@ impl ChatComposer { local_image_paths: Vec, mention_bindings: Vec, ) { + if !self.sparkle.history_preview { + if local_image_paths.is_empty() { + self.note_sparkle_replaced_text(&text); + } else { + self.dismiss_sparkle(); + } + } // Clear any existing content, placeholders, and attachments first. self.footer.flash = None; self.vim_history = VimHistory::default(); @@ -1759,7 +1718,7 @@ impl ChatComposer { /// Flush buffered typing before clearing so cancellation preserves the complete draft in history. pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } if self.is_empty() { return None; @@ -1844,6 +1803,7 @@ impl ChatComposer { pending_pastes: self.pending_pastes(), startup_local_history: self.history.startup_local_history().to_vec(), last_composer_activity_at: None, + sparkle_draft: self.sparkle.draft.get(), } } @@ -1881,6 +1841,7 @@ impl ChatComposer { /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { + self.dismiss_sparkle(); let started_vim_edit = self.begin_direct_vim_edit(); self.attachments .attach_image(&mut self.draft.textarea, path); @@ -1903,7 +1864,8 @@ impl ChatComposer { /// /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: /// - /// - If a burst times out, flush it via `handle_paste(String)`. + /// - If a burst times out, integrate it via [`Self::apply_paste`]. Its key events have already + /// been classified; [`Self::handle_paste`] classifies explicit terminal pastes instead. /// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it /// as normal typed input. /// @@ -2012,6 +1974,9 @@ impl ChatComposer { } pub(crate) fn insert_str(&mut self, text: &str) { + if !text.is_empty() && self.sparkle.draft.get() == sparkle::SparkleDraft::Untouched { + self.dismiss_sparkle(); + } let started_vim_edit = self.begin_direct_vim_edit(); self.draft.textarea.insert_str(text); self.sync_bash_mode_from_text(); @@ -2031,6 +1996,13 @@ impl ChatComposer { return (InputResult::None, false); } + let before = self.before_sparkle_key(key_event); + let result = self.handle_key_event_inner(key_event); + self.after_sparkle_key(before, &result.0); + result + } + + fn handle_key_event_inner(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.history_search.is_none() && !self.popups.active() && self.draft.textarea.wants_vim_search_key(key_event) @@ -2128,7 +2100,7 @@ impl ChatComposer { // any existing burst buffer (including a pending first char from the ASCII path) so // we don't carry that transient state forward. if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } if let Some(decision) = self.draft.paste_burst.on_plain_char_no_hold(now) { match decision { @@ -2165,7 +2137,7 @@ impl ChatComposer { } } if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } self.draft.textarea.input(input); @@ -3232,7 +3204,7 @@ impl ChatComposer { } if should_queue { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } let visible_shell_command = self.is_bang_shell_command(); let original_input = self.current_text(); @@ -3532,7 +3504,7 @@ impl ChatComposer { } if self.draft.is_bash_mode && key_event.code == KeyCode::Esc { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } if self.draft.textarea.is_empty() { self.draft.is_bash_mode = false; @@ -3643,28 +3615,6 @@ impl ChatComposer { .map(|_| Line::from(vec![Span::from("Shell mode").light_red()])) } - /// Applies any due `PasteBurst` flush at time `now`. - /// - /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. - /// - /// Callers: - /// - /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. - /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. - fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { - match self.draft.paste_burst.flush_if_due(now) { - FlushResult::Paste(pasted) => { - self.handle_paste(pasted); - true - } - FlushResult::Typed(ch) => { - self.insert_str(ch.to_string().as_str()); - true - } - FlushResult::None => false, - } - } - /// Handles keys that mutate the textarea, including paste-burst detection. /// /// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain @@ -3779,7 +3729,7 @@ impl ChatComposer { } } if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } } @@ -3792,7 +3742,7 @@ impl ChatComposer { if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter) && let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } // For non-char inputs (or after flushing), handle normally. // Track element removals so we can drop any corresponding placeholders without scanning @@ -3887,23 +3837,29 @@ impl ChatComposer { return true; } - let toggles = self.footer.hint_override.is_none() + let Some(next) = self.empty_prompt_shortcut_toggle(key_event) else { + return false; + }; + self.footer.mode = next; + true + } + + fn empty_prompt_shortcut_toggle(&self, key_event: &KeyEvent) -> Option { + if key_event.kind == KeyEventKind::Press + && self.footer.hint_override.is_none() && self.toggle_shortcuts_keys.is_pressed(*key_event) && self.is_empty() - && !self.is_in_paste_burst(); - - if !toggles { - return false; + && !self.is_in_paste_burst() + { + let next = toggle_shortcut_mode( + self.footer.mode, + self.quit_shortcut_hint_visible(), + self.is_empty(), + ); + (next != self.footer.mode).then_some(next) + } else { + None } - - let next = toggle_shortcut_mode( - self.footer.mode, - self.quit_shortcut_hint_visible(), - self.is_empty(), - ); - let changed = next != self.footer.mode; - self.footer.mode = next; - changed } fn footer_props(&self) -> FooterProps { @@ -5075,6 +5031,12 @@ impl ChatComposer { } drop(state); self.render_voice_strip(composer_rect, buf); + self.render_sparkle( + composer_rect, + textarea_rect, + self.cursor_pos_with_textarea_right_reserve(area, textarea_right_reserve), + buf, + ); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index 419a3b6f2592..5ce24e7392c9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -16,7 +16,8 @@ //! latest history entry by itself. Typing or pasting a query restarts traversal from newest to oldest, //! repeated Ctrl+R/Up and Ctrl+S/Down move between unique matches, `Enter` accepts the current //! preview as an editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before -//! search started. +//! search started. Buffered keys are integrated before that snapshot without reclassifying them as +//! an explicit paste. Only accepting a nonempty preview dismisses any unused Astra sparkle opportunity. use std::ops::Range; @@ -110,14 +111,14 @@ impl ChatComposer { /// Opens footer-owned reverse history search without previewing history yet. /// - /// Entering search mode first flushes pending paste-burst text, then snapshots the full - /// composer draft, clears any file/search popup state, and resets history traversal. The first - /// visible match is produced only after the footer query becomes non-empty, which keeps Ctrl+R - /// from replacing an empty composer with the latest prompt before the user has searched for - /// anything. + /// Entering search mode first integrates already-classified paste-burst text with + /// [`Self::apply_paste`], then snapshots the full composer draft, clears any file/search popup + /// state, and resets history traversal. The first visible match is produced only after the + /// footer query becomes non-empty, which keeps Ctrl+R from replacing an empty composer with + /// the latest prompt before the user has searched for anything. pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); + self.apply_paste(pasted); } self.draft.paste_burst.clear_window_after_non_char(); @@ -206,6 +207,9 @@ impl ChatComposer { .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) { self.history_search = None; + if !self.is_empty() { + self.dismiss_sparkle(); + } self.history.reset_search(); self.footer.mode = reset_mode_after_activity(self.footer.mode); self.move_cursor_to_end(); @@ -259,7 +263,7 @@ impl ChatComposer { if let Some(search) = self.history_search.as_mut() { search.status = HistorySearchStatus::Idle; } - self.restore_draft(original_draft); + self.with_sparkle_history_preview(|composer| composer.restore_draft(original_draft)); return InputResult::None; } let result = self.history.search( @@ -282,7 +286,7 @@ impl ChatComposer { search.status = HistorySearchStatus::Searching; let query = search.query.clone(); let original_draft = search.original_draft.clone(); - self.restore_draft(original_draft); + self.with_sparkle_history_preview(|composer| composer.restore_draft(original_draft)); if query.is_empty() { self.history.reset_search(); if let Some(search) = self.history_search.as_mut() { @@ -311,7 +315,7 @@ impl ChatComposer { }; self.history.reset_navigation(); self.footer.mode = reset_mode_after_activity(self.footer.mode); - self.restore_draft(search.original_draft); + self.with_sparkle_history_preview(|composer| composer.restore_draft(search.original_draft)); self.vim_history = search.original_vim_history; self.draft .textarea @@ -333,7 +337,7 @@ impl ChatComposer { if let Some(search) = self.history_search.as_mut() { search.status = HistorySearchStatus::Match; } - self.apply_history_entry(entry); + self.with_sparkle_history_preview(|composer| composer.apply_history_entry(entry)); } HistorySearchResult::Pending => { if let Some(search) = self.history_search.as_mut() { @@ -358,12 +362,21 @@ impl ChatComposer { }; } if let Some(original_draft) = original_draft { - self.restore_draft(original_draft); + self.with_sparkle_history_preview(|composer| { + composer.restore_draft(original_draft) + }); } } } } + fn with_sparkle_history_preview(&mut self, preview: impl FnOnce(&mut Self)) { + let previous = self.sparkle.history_preview; + self.sparkle.history_preview = true; + preview(self); + self.sparkle.history_preview = previous; + } + /// Builds the footer line shown while reverse history search is active. /// /// The footer displays the query as the editable field and uses the status to decide whether diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs index eb348d49c13a..643ff56f1e56 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs @@ -66,10 +66,10 @@ impl ChatComposer { || self.vim_normal_keymap.move_down.is_pressed(key))) } - /// Finish buffered typing before another answer takes over this editor. + /// Integrate already-classified buffered typing before another answer takes over this editor. pub(crate) fn flush_pending_input(&mut self) { if let Some(text) = self.draft.paste_burst.flush_before_modified_input() { - self.handle_paste(text); + self.apply_paste(text); } self.draft.paste_burst.clear_after_explicit_paste(); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index faf6d54fa964..380949f5aef3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -1,8 +1,124 @@ -//! Capture raw paste tabs before completion and submission shortcuts see them. +//! Classify and integrate composer pastes and capture raw paste tabs before completion or submission. use super::*; +use crate::bottom_pane::paste_burst::FlushResult; impl ChatComposer { + /// Classify an explicit paste before integrating text shared with the buffered key path. + pub fn handle_paste(&mut self, pasted: String) -> bool { + self.note_sparkle_paste(&pasted); + self.apply_paste(pasted) + } + + /// Enable or disable paste-burst handling. + /// + /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic + /// is unwanted or has already been handled elsewhere. + /// + /// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it + /// cannot affect subsequent normal typing: + /// + /// - First, flush held/buffered text via [`PasteBurst::flush_before_modified_input`] and + /// integrate it with [`Self::apply_paste`]. Its key events have already been classified; + /// [`Self::handle_paste`] classifies explicit terminal pastes before using the same + /// integration path (large-paste placeholders, image-path detection, and popup sync). + /// - Then clear the burst timing and Enter-suppression window via + /// [`PasteBurst::clear_after_explicit_paste`]. + /// + /// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state + /// without emitting any buffered text, which can leave a non-empty buffer unable to flush + /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { + let was_disabled = self.draft.disable_paste_burst; + self.draft.disable_paste_burst = disabled; + if disabled && !was_disabled { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.apply_paste(pasted); + } + self.draft.paste_burst.clear_after_explicit_paste(); + } + } + + /// Applies any due `PasteBurst` flush at time `now`. + /// + /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. + /// Buffered keys have already been classified; paste results use [`Self::apply_paste`]. + /// + /// Callers: + /// + /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. + /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. + pub(super) fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { + match self.draft.paste_burst.flush_if_due(now) { + FlushResult::Paste(pasted) => { + self.apply_paste(pasted); + true + } + FlushResult::Typed(ch) => { + self.insert_str(ch.to_string().as_str()); + true + } + FlushResult::None => false, + } + } + + /// Integrate pasted text into the composer. + /// + /// Both input paths integrate their text here: + /// + /// - Real/explicit paste events surfaced by the terminal, and + /// - Non-bracketed "paste bursts" that + /// [`PasteBurst`](crate::bottom_pane::paste_burst::PasteBurst) buffers and later flushes here. + /// + /// Behavior: + /// + /// - If history search is active, inserts nonempty text into its query and ignores empty pastes. + /// - If Vim search is active, inserts text into its query. + /// - Otherwise, if the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a + /// placeholder element (expanded on submit) and stores the full text in `pending_pastes`. + /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a + /// trailing space so the user can keep typing naturally. + /// - Otherwise, inserts the pasted text directly into the textarea. + /// + /// Composer edits clear paste-burst Enter suppression and sync popups. + pub(super) fn apply_paste(&mut self, pasted: String) -> bool { + let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); + let pasted = sanitize_user_text(pasted.into()); + if self.history_search.is_some() { + if !pasted.is_empty() { + self.update_history_search_query(|query| query.push_str(&pasted)); + } + return true; + } + if let Some(query) = self.draft.textarea.vim_query_mut() { + query.editor.insert_str(&pasted); + return true; + } + let started_vim_edit = self.begin_direct_vim_edit(); + let char_count = pasted.chars().count(); + if char_count > LARGE_PASTE_CHAR_THRESHOLD { + let placeholder = self.next_large_paste_placeholder(char_count); + self.draft.textarea.insert_element(&placeholder); + self.draft + .pending_pastes + .push((placeholder, pasted.into_owned())); + } else if char_count > 1 + && self.image_paste_enabled() + && self.handle_paste_image_path(&pasted) + { + let cursor = self.draft.textarea.cursor(); + self.draft.textarea.insert_str_at(cursor, " "); + } else { + self.insert_str(&pasted); + } + self.draft.paste_burst.clear_after_explicit_paste(); + self.sync_popups(); + if started_vim_edit { + self.finish_vim_edit(); + } + true + } + pub(super) fn handle_paste_tab(&mut self, key: KeyEvent, now: Instant) -> bool { if key.code != KeyCode::Tab || !key.modifiers.is_empty() diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs index b0166f1fbfec..aea6d8458076 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -1,5 +1,6 @@ //! Offline editing retains the draft in place. //! Paste Enter handling is shared with normal submission so buffered newlines survive both paths. +//! Offline draft edits also consume the Astra sparkle opportunity before rendering. use super::*; @@ -66,7 +67,14 @@ impl ChatComposer { if !matches!(key.code, KeyCode::Enter | KeyCode::Tab) && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { - self.handle_input_basic(key); + // Null is sent internally to clean up on disconnect or expand a paste; it isn't an edit. + let before = if key.code == KeyCode::Null { + None + } else { + self.before_sparkle_editor_key(key) + }; + let (result, _) = self.handle_input_basic(key); + self.after_sparkle_key(before, &result); } } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap new file mode 100644 index 000000000000..60125b16ac17 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "format!(\"command during startup:\\n{}\\n\\nafter command:\\n{}\", text(&pending),\ntext(&completed))" +--- +command during startup: + +› /status + + /status show current session configuration and token usage + /statusline configure which items appear in the status line + +after command: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__canceled_history_search_restores_visible_stars_only_before_the_original_deadline.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__canceled_history_search_restores_visible_stars_only_before_the_original_deadline.snap new file mode 100644 index 000000000000..baa784f4f980 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__canceled_history_search_restores_visible_stars_only_before_the_original_deadline.snap @@ -0,0 +1,37 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +expires while open: false +initial: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left +searching: + +› stored draft + + reverse-i-search: stored enter accept · esc cancel +after escape: + ⠁ ⡀ ⠄ ⠄ ⠄ +› Ask Codex to do anything ⠈ + ⠠ ⠠ ⢀ ⢀ ⡀ ⠄ + ? for shortcuts 100% context left + +expires while open: true +initial: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left +searching: + +› stored draft + + reverse-i-search: stored enter accept · esc cancel +after escape: + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__disconnected_sparkle_edits_are_tracked_between_renders.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__disconnected_sparkle_edits_are_tracked_between_renders.snap new file mode 100644 index 000000000000..0c60de5e7ffb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__disconnected_sparkle_edits_are_tracked_between_renders.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +gpt-6-astra +before disconnect: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left +after typing and erasing without rendering: + +› Ask Codex to do anything + + ? for shortcuts 100% context left + +gpt-5.5 +before disconnect: + +› Ask Codex to do anything + + ? for shortcuts 100% context left +after typing and erasing without rendering: + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_acceptance_and_cancellation_update_sparkles_before_rendering.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_acceptance_and_cancellation_update_sparkles_before_rendering.snap new file mode 100644 index 000000000000..a671d9f4adcb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_acceptance_and_cancellation_update_sparkles_before_rendering.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +accept: + +› Ask Codex to do anything + + ? for shortcuts 100% context left + +escape: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_preserves_a_held_sparkle_command_without_reclassifying_it.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_preserves_a_held_sparkle_command_without_reclassifying_it.snap new file mode 100644 index 000000000000..80bcb14f707b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__history_search_preserves_a_held_sparkle_command_without_reclassifying_it.snap @@ -0,0 +1,37 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +gpt-6-astra +searching with an explicit pasted query: + +› history prompt + + reverse-i-search: history enter accept · esc cancel +after canceling and erasing the slash: + ⠁ ⡀ ⠄ ⠄ ⠄ +› Ask Codex to do anything ⠈ + ⠠ ⠠ ⢀ ⢀ ⡀ ⠄ + ? for shortcuts 100% context left +after /model selects Astra: + ⠁ ⡀ ⠄ ⠄ ⠄ +› Ask Codex to do anything ⠈ + ⠠ ⠠ ⢀ ⢀ ⡀ ⠄ + ? for shortcuts 100% context left + +gpt-5.5 +searching with an explicit pasted query: + +› history prompt + + reverse-i-search: history enter accept · esc cancel +after canceling and erasing the slash: + +› Ask Codex to do anything + + ? for shortcuts 100% context left +after /model selects Astra: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__review_regression_canceling_a_held_slash_restores_sparkle_command_tracking.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__review_regression_canceling_a_held_slash_restores_sparkle_command_tracking.snap new file mode 100644 index 000000000000..0301676391df --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__review_regression_canceling_a_held_slash_restores_sparkle_command_tracking.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +gpt-6-astra +after immediately canceling the held slash: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left +after /model: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left + +gpt-5.5 +after immediately canceling the held slash: + +› Ask Codex to do anything + + ? for shortcuts 100% context left +after /model: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap new file mode 100644 index 000000000000..a9ac149b2771 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap @@ -0,0 +1,53 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +expires during help: false +initial: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left +shortcut help: + +› Ask Codex to do anything + + / for commands ! for shell commands + ctrl + j for newline tab to submit message + @ for file paths ctrl + v to paste images + ctrl + g to edit in external editor esc esc to edit previous message + ctrl + r search history ctrl + c to exit + ⌥ + , reasoning down ⌥ + . reasoning up + ctrl + t to view transcript + + customize shortcuts with /keymap +after closing: + ⠁ ⡀ ⠄ ⠄ ⠄ +› Ask Codex to do anything ⠈ + ⠠ ⠠ ⢀ ⢀ ⡀ ⠄ + ? for shortcuts 100% context left + +expires during help: true +initial: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left +shortcut help: + +› Ask Codex to do anything + + / for commands ! for shell commands + ctrl + j for newline tab to submit message + @ for file paths ctrl + v to paste images + ctrl + g to edit in external editor esc esc to edit previous message + ctrl + r search history ctrl + c to exit + ⌥ + , reasoning down ⌥ + . reasoning up + ctrl + t to view transcript + + customize shortcuts with /keymap +after closing: + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap new file mode 100644 index 000000000000..69f211f7e996 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +shortcut help: + +› Ask Codex to do anything + + / for commands ! for shell commands + ctrl + j for newline tab to submit message + @ for file paths ctrl + v to paste images + ctrl + g to edit in external editor esc esc to edit previous message + ctrl + r search history ctrl + c to exit + ⌥ + , reasoning down ⌥ + . reasoning up + ctrl + t to view transcript + + customize shortcuts with /keymap + +after /model selects Astra: + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left + +literal pasted question mark: + +› ? + + 100% context left + +after clearing and selecting Astra: + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_fades_without_adding_dots_and_finishes_by_fifteen_seconds.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_fades_without_adding_dots_and_finishes_by_fifteen_seconds.snap new file mode 100644 index 000000000000..0cfba1ed133a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_fades_without_adding_dots_and_finishes_by_fifteen_seconds.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "format!(\"before fade:\\n{}\\nfading:\\n{}\\nfinished:\\n{}\", text(&last),\ntext(&fading), text(&finished))" +--- +before fade: +⠁ ⠄ ⢀ ⠈ ⠁ ⡀ ⠂ ⠄ +› Ask Codex to do anything ⠈ ⠈ ⠂ ⠁ ⠂ + ⢀⠐ ⠄ ⠄ ⢀ ⠠ ⠄ + ? for shortcuts 100% context left +fading: +⠁ ⠄ ⢀ ⠈ ⠁ ⡀ ⠂ ⠄ +› Ask Codex to do anything ⠈ ⠈ ⠂ ⠁ + ⠐ ⠄ ⠄ ⢀ ⠠ ⠄ + ? for shortcuts 100% context left +finished: + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_matches_the_original_starfield_and_protects_the_placeholder_and_cursor.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_matches_the_original_starfield_and_protects_the_placeholder_and_cursor.snap new file mode 100644 index 000000000000..9edbf115eeb1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__sparkle_matches_the_original_starfield_and_protects_the_placeholder_and_cursor.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +40 columns, cursor Some((2, 1)) + ⠈ ⢀ +›⠁Ask Codex to do anything ⠈ + ⢀ ⠠ ⠄ ⠠ + ? for shortcuts 100% context left +first input: + +› x + + 100% context left + +80 columns, cursor Some((2, 1)) + ⠈ ⢀ ⠁ ⠂ +›⠁Ask Codex to do anything ⠈ ⠁ + ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ + ? for shortcuts 100% context left +first input: + +› x + + 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs new file mode 100644 index 000000000000..6e365938a3d3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs @@ -0,0 +1,426 @@ +//! One short Astra flourish for a confirmed new task with an untouched prompt. +//! +//! Ordinary draft content ends the opportunity permanently; slash commands can still select Astra +//! if the flourish has never started. The original field fills blank composer cells without drawing +//! over the placeholder or normal terminal cursor, and hidden frames do not schedule work. The +//! separate field renderer only paints its buffer and has no input or scheduling state. History +//! search hides an active field while its original deadline continues; its query and previews do +//! not count as input before acceptance. Shortcut help on an empty composer also hides an active +//! field without restarting its deadline and leaves an unused opportunity intact. The disconnected +//! editor shares draft tracking but treats composer-only shortcuts as ordinary editor input. A key +//! that finishes a paste burst reconciles the draft even if it removes a held slash in the same edit. + +use std::cell::Cell; +use std::cell::RefCell; +use std::time::Duration; +use std::time::Instant; + +use codex_config::types::Tui; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use unicode_width::UnicodeWidthStr; + +use super::ChatComposer; +use super::FooterMode; +use super::InputResult; +use crate::bottom_pane::BottomPane; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::effective_stdout_color_level; + +#[path = "sparkle_field.rs"] +mod field; + +const FRAME_TICK: Duration = Duration::from_millis(/*millis*/ 150); +const IDLE_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 15); +const IDLE_FADE: Duration = Duration::from_secs(/*secs*/ 1); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum SparkleDraft { + #[default] + Untouched, + Command, + Dismissed, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum Phase { + #[default] + Unarmed, + Waiting, + Visible(Instant), + Finished, +} + +pub(super) struct SparkleEditorState { + text: String, + paste_burst_active: bool, +} + +pub(super) struct Sparkle { + pub(super) draft: Cell, + /// Scoped to history's own replacements; other draft changes still count while search is open. + pub(super) history_preview: bool, + command_input: RefCell, + phase: Cell, + fresh: bool, + terminal_focused: bool, +} + +impl Default for Sparkle { + fn default() -> Self { + Self { + draft: Cell::new(SparkleDraft::Untouched), + history_preview: false, + command_input: RefCell::new(String::new()), + phase: Cell::new(Phase::Unarmed), + fresh: false, + terminal_focused: true, + } + } +} + +impl BottomPane { + pub(crate) fn is_sparkle_model(model: &str) -> bool { + model + .split(|ch: char| !ch.is_alphanumeric() && ch != '_') + .any(|part| part.eq_ignore_ascii_case("astra")) + } + + pub(crate) fn mark_fresh_task_for_sparkle(&mut self, model: &str, settings: &Tui) { + self.composer.sparkle.fresh = true; + if matches!( + self.composer.sparkle.draft.get(), + SparkleDraft::Untouched | SparkleDraft::Command + ) && self.composer.sparkle.phase.get() == Phase::Unarmed + && settings.animations + && settings.whimsy + && Self::is_sparkle_model(model) + { + self.composer.sparkle.phase.set(Phase::Waiting); + self.request_redraw(); + } + } + + pub(crate) fn select_sparkle_model(&mut self, model: &str, settings: &Tui) { + if self.composer.sparkle.fresh { + self.mark_fresh_task_for_sparkle(model, settings); + } + } + + pub(crate) fn stop_ineligible_sparkle(&self, model: &str, settings: &Tui) { + if !settings.animations || !settings.whimsy || !Self::is_sparkle_model(model) { + self.composer.stop_visible_sparkle(); + } + } + + pub(crate) fn dismiss_composer_sparkle(&self) { + self.composer.dismiss_sparkle(); + } + + pub(crate) fn prepare_composer_sparkle_key(&self, key: KeyEvent) { + if !self.has_active_view() { + self.composer.prepare_sparkle_key(key); + } + } + + pub(crate) fn inherit_startup_sparkle(&self, draft: SparkleDraft) { + if self.composer.sparkle.draft.get() == SparkleDraft::Untouched + && draft != SparkleDraft::Untouched + { + self.composer.sparkle.draft.set(draft); + if draft == SparkleDraft::Dismissed { + self.composer.stop_visible_sparkle(); + } + } + } + + pub(crate) fn set_sparkle_terminal_focus(&mut self, focused: bool) { + if self.composer.sparkle.terminal_focused != focused { + self.composer.sparkle.terminal_focused = focused; + self.request_redraw(); + } + } +} + +impl ChatComposer { + fn prepare_sparkle_key(&self, key: KeyEvent) { + if self.sparkle.draft.get() != SparkleDraft::Command + && !(key.code == KeyCode::Char('/') + && crate::key_hint::is_plain_text_key_event(key) + && self.is_empty() + && self.slash_commands_enabled()) + { + self.stop_visible_sparkle(); + } + } + + fn stop_visible_sparkle(&self) { + if matches!(self.sparkle.phase.get(), Phase::Waiting | Phase::Visible(_)) { + self.sparkle.phase.set(Phase::Finished); + if let Some(requester) = &self.frame_requester { + requester.schedule_frame(); + } + } + } + + pub(super) fn dismiss_sparkle(&self) { + self.sparkle.draft.set(SparkleDraft::Dismissed); + self.sparkle.command_input.borrow_mut().clear(); + self.stop_visible_sparkle(); + } + + pub(super) fn before_sparkle_key(&self, key: KeyEvent) -> Option { + if self.history_search.is_some() + || (Self::is_history_search_key(&key, &self.history_search_previous_keys) + && (self.popups.active() || !self.draft.textarea.wants_vim_search_key(key)) + && !self.wants_vim_history_key(key)) + || self.empty_prompt_shortcut_toggle(&key).is_some() + { + return None; + } + self.before_sparkle_editor_key(key) + } + + pub(super) fn before_sparkle_editor_key(&self, key: KeyEvent) -> Option { + self.prepare_sparkle_key(key); + if self.draft.textarea.vim_query().is_some() { + return None; + } + if let KeyCode::Char(ch) = key.code + && crate::key_hint::is_plain_text_key_event(key) + && !key + .modifiers + .intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) + && !self.draft.textarea.is_vim_normal_mode() + { + match self.sparkle.draft.get() { + SparkleDraft::Untouched + if ch == '/' && self.is_empty() && self.slash_commands_enabled() => + { + self.sparkle.draft.set(SparkleDraft::Command); + self.sparkle.command_input.replace(ch.to_string()); + } + SparkleDraft::Command => { + let actual = self.current_text(); + let mut projected = self.sparkle.command_input.borrow().clone(); + let cursor = if actual == projected { + self.current_cursor() + } else { + projected.len() + }; + projected.insert(cursor, ch); + if self.is_sparkle_command(&projected) { + self.sparkle.command_input.replace(projected); + } else { + self.dismiss_sparkle(); + } + } + SparkleDraft::Untouched => self.dismiss_sparkle(), + SparkleDraft::Dismissed => {} + } + None + } else { + Some(SparkleEditorState { + text: self.current_text(), + paste_burst_active: self.is_in_paste_burst(), + }) + } + } + + pub(super) fn after_sparkle_key( + &self, + before: Option, + result: &InputResult, + ) { + match result { + InputResult::Command(_) | InputResult::ServiceTierCommand(_) => { + if self.sparkle.draft.get() == SparkleDraft::Command { + self.sparkle.draft.set(SparkleDraft::Untouched); + self.sparkle.command_input.borrow_mut().clear(); + } + } + InputResult::Submitted { .. } + | InputResult::Queued { .. } + | InputResult::ParentOwnedInputBlocked => self.dismiss_sparkle(), + InputResult::CommandWithArgs(_, _, _) | InputResult::None => { + if self.history_search.is_none() && self.draft.textarea.vim_query().is_none() { + let draft = self.current_text(); + if before.is_some_and(|before| { + before.text != draft + || (before.paste_burst_active && !self.is_in_paste_burst()) + }) { + self.note_sparkle_replaced_text(&draft); + } + } + } + } + } + + pub(super) fn note_sparkle_paste(&self, text: &str) { + if self.history_search.is_some() { + return; + } + if self.sparkle.draft.get() != SparkleDraft::Command + && !(self.is_empty() && self.is_sparkle_command(text)) + { + self.stop_visible_sparkle(); + } + if !text.is_empty() && self.draft.textarea.vim_query().is_none() { + match self.sparkle.draft.get() { + SparkleDraft::Untouched if self.is_empty() && self.is_sparkle_command(text) => { + self.sparkle.draft.set(SparkleDraft::Command); + self.sparkle.command_input.replace(text.to_string()); + } + SparkleDraft::Command => { + let mut projected = self.sparkle.command_input.borrow().clone(); + let actual = self.current_text(); + let cursor = if actual == projected { + self.current_cursor() + } else { + projected.len() + }; + projected.insert_str(cursor, text); + if self.is_sparkle_command(&projected) { + self.sparkle.command_input.replace(projected); + } else { + self.dismiss_sparkle(); + } + } + SparkleDraft::Untouched => self.dismiss_sparkle(), + SparkleDraft::Dismissed => {} + } + } + } + + pub(super) fn note_sparkle_replaced_text(&self, text: &str) { + match self.sparkle.draft.get() { + SparkleDraft::Command if text.is_empty() => { + self.sparkle.draft.set(SparkleDraft::Untouched); + self.sparkle.command_input.borrow_mut().clear(); + } + SparkleDraft::Command if self.is_sparkle_command(text) => { + self.sparkle.command_input.replace(text.to_string()); + } + SparkleDraft::Untouched if text.is_empty() => {} + SparkleDraft::Untouched | SparkleDraft::Command => self.dismiss_sparkle(), + SparkleDraft::Dismissed => {} + } + } + + fn is_sparkle_command(&self, text: &str) -> bool { + let Some(body) = text.strip_prefix('/') else { + return false; + }; + if !self.slash_commands_enabled() || self.draft.is_bash_mode || text.contains(['\n', '\r']) + { + return false; + } + let split = body.find(char::is_whitespace).unwrap_or(body.len()); + let (name, rest) = body.split_at(split); + if rest.is_empty() { + self.slash_input().is_editing_command_name(text, text.len()) + || self.slash_input().command(name).is_some() + } else { + self.slash_input() + .command(name) + .is_some_and(|command| rest.trim().is_empty() || command.supports_inline_args()) + } + } + + pub(super) fn render_sparkle( + &self, + area: Rect, + textarea: Rect, + cursor: Option<(u16, u16)>, + buf: &mut Buffer, + ) { + self.render_sparkle_at(area, textarea, cursor, Instant::now(), buf); + } + + fn render_sparkle_at( + &self, + area: Rect, + textarea: Rect, + cursor: Option<(u16, u16)>, + now: Instant, + buf: &mut Buffer, + ) { + if self.history_search.is_none() + && self.draft.textarea.vim_query().is_none() + && !self.is_empty() + { + let is_command = self.sparkle.draft.get() == SparkleDraft::Command + && self.is_sparkle_command(&self.current_text()) + && self.attachments.is_empty(); + if !is_command { + self.dismiss_sparkle(); + } + } + let since = match self.sparkle.phase.get() { + Phase::Visible(since) => Some(since), + Phase::Waiting => None, + Phase::Unarmed | Phase::Finished => return, + }; + let elapsed = since.map_or(Duration::ZERO, |since| now.saturating_duration_since(since)); + if elapsed >= IDLE_TIMEOUT { + self.stop_visible_sparkle(); + return; + } + if !self.has_focus + || !self.sparkle.terminal_focused + || !self.is_empty() + || self.sparkle.draft.get() == SparkleDraft::Command + || self.draft.paste_burst.is_active() + || !self.draft.input_enabled + || self.is_task_running + || self.voice_strip.is_some() + || self.popup_active() + || self.footer_mode() == FooterMode::ShortcutOverlay + || area.height < 3 + || textarea.is_empty() + || effective_stdout_color_level() != StdoutColorLevel::TrueColor + { + return; + } + let Some(foreground) = default_fg() else { + return; + }; + if since.is_none() { + self.sparkle.phase.set(Phase::Visible(now)); + } + let fade_start = IDLE_TIMEOUT - IDLE_FADE; + let visibility = if elapsed > fade_start { + (IDLE_TIMEOUT - elapsed).as_secs_f32() / IDLE_FADE.as_secs_f32() + } else { + 1.0 + }; + let protected = Rect::new( + textarea.x, + textarea.y, + self.placeholder_text + .width() + .min(usize::from(textarea.width)) as u16, + /*height*/ 1, + ); + field::render_stars( + area, + cursor, + Some(protected), + elapsed.min(fade_start), + foreground, + visibility, + buf, + ); + if let Some(requester) = &self.frame_requester { + requester.schedule_frame_in(FRAME_TICK.min(IDLE_TIMEOUT - elapsed)); + } + } +} + +#[cfg(test)] +#[path = "sparkle_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_field.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_field.rs new file mode 100644 index 000000000000..7791cc0c0f55 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_field.rs @@ -0,0 +1,70 @@ +//! Paint the deterministic Astra starfield without owning its eligibility, timer, or redraws. +//! +//! Only blank, unstyled true-color cells outside protected content and the terminal cursor can be +//! decorated. The caller supplies elapsed time and visibility to preserve the original appearance. + +use std::time::Duration; + +use ratatui::buffer::Buffer; +use ratatui::buffer::CellDiffOption; +use ratatui::layout::Position; +use ratatui::layout::Rect; +use ratatui::style::Color; +use unicode_width::UnicodeWidthStr; + +use crate::color::blend; +use crate::terminal_palette::rgb_color; + +pub(super) const DOTS: [&str; 8] = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⡀", "⢀"]; + +pub(super) fn render_stars( + area: Rect, + cursor: Option<(u16, u16)>, + protected_area: Option, + elapsed: Duration, + foreground: (u8, u8, u8), + visibility: f32, + buf: &mut Buffer, +) { + let time = elapsed.as_secs_f32(); + for y in area.y..area.bottom() { + let mut occupied_until = area.x; + for x in area.x..area.right() { + let cell = &buf[(x, y)]; + if x < occupied_until { + continue; + } + if cell.symbol() != " " { + occupied_until = x.saturating_add(cell.symbol().width() as u16); + continue; + } + if cursor == Some((x, y)) + || protected_area.is_some_and(|protected| protected.contains(Position::new(x, y))) + || !cell.modifier.is_empty() + || cell.diff_option != CellDiffOption::None + { + continue; + } + let Color::Rgb(r, g, b) = cell.bg else { + continue; + }; + let mut hash = u64::from(y - area.y) * 65537 + u64::from(x - area.x); + hash = (hash ^ (hash >> 16)).wrapping_mul(/*rhs*/ 0x45d9f3b); + hash = (hash ^ (hash >> 16)).wrapping_mul(/*rhs*/ 0x45d9f3b); + hash ^= hash >> 16; + if hash % 5 != 0 { + continue; + } + let phase = + (time / (4.0 + (hash % 31) as f32 / 10.0) + (hash % 997) as f32 / 997.0).fract(); + let brightness = + (phase * std::f32::consts::PI).sin().powi(/*n*/ 12) * 0.55 * visibility; + if brightness < 0.04 { + continue; + } + buf[(x, y)] + .set_symbol(DOTS[(hash / 161 % 8) as usize]) + .set_fg(rgb_color(blend(foreground, (r, g, b), brightness))); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs new file mode 100644 index 000000000000..9ba12a09d8e2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs @@ -0,0 +1,1073 @@ +//! Covers the original starfield appearance, protected content, and composer eligibility. + +use super::*; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneParams; +use crate::bottom_pane::chat_composer_history::HistoryEntry; +use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use crate::tui::FrameRequester; +use pretty_assertions::assert_eq; +use ratatui::layout::Position; +use ratatui::style::Color; + +use super::field::DOTS; + +fn pane() -> BottomPane { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + BottomPane::new(BottomPaneParams { + app_event_tx: AppEventSender::new(tx), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".into(), + disable_paste_burst: true, + animations_enabled: true, + skills: None, + }) +} + +fn palette(render: impl FnOnce() -> T) -> T { + with_test_default_colors( + DefaultColors { + fg: (230, 216, 255), + bg: (36, 27, 53), + }, + render, + ) +} + +fn enabled() -> Tui { + Tui { + animations: true, + whimsy: true, + ..Tui::default() + } +} + +fn draw(composer: &ChatComposer, width: u16, now: Instant) -> (Buffer, Rect) { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + composer.desired_height(width), + ); + let [padding, _, textarea, _] = + composer.layout_areas_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0); + let phase = composer.sparkle.phase.replace(Phase::Unarmed); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + composer.sparkle.phase.set(phase); + composer.render_sparkle_at( + padding, + textarea, + composer.cursor_pos(area), + now, + &mut buffer, + ); + (buffer, textarea) +} + +fn text(buffer: &Buffer) -> String { + buffer + .content + .chunks(usize::from(buffer.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +fn dots(buffer: &Buffer) -> Vec<(u16, u16, String)> { + buffer + .content + .iter() + .enumerate() + .filter(|&(_, cell)| DOTS.contains(&cell.symbol())) + .map(|(index, cell)| { + let (x, y) = buffer.pos_of(index); + (x, y, cell.symbol().to_string()) + }) + .collect() +} + +fn key(pane: &mut BottomPane, key: KeyCode) -> InputResult { + pane.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)) +} + +fn type_text(pane: &mut BottomPane, text: &str) { + for ch in text.chars() { + key(pane, KeyCode::Char(ch)); + } +} + +#[test] +fn sparkle_matches_the_original_starfield_and_protects_the_placeholder_and_cursor() { + palette(|| { + let now = Instant::now(); + let mut snapshots = Vec::new(); + for width in [40, 80] { + let mut pane = pane(); + let (plain, _) = draw(&pane.composer, width, now); + let cursor = pane.composer.cursor_pos(plain.area); + pane.mark_fresh_task_for_sparkle("gpt-6-astra", &enabled()); + let (active, textarea) = draw(&pane.composer, width, now); + assert_eq!(pane.composer.cursor_pos(active.area), cursor); + let (later, _) = draw(&pane.composer, width, now + Duration::from_secs(/*secs*/ 2)); + let protected = Rect::new( + textarea.x, + textarea.y, + pane.composer.placeholder_text.width() as u16, + /*height*/ 1, + ); + let placeholder = |buffer: &Buffer| { + (protected.x..protected.right()) + .map(|x| buffer[(x, protected.y)].symbol()) + .collect::() + }; + assert_eq!(placeholder(&active), placeholder(&plain)); + assert_eq!(placeholder(&later), placeholder(&plain)); + assert!( + dots(&active) + .iter() + .chain(dots(&later).iter()) + .all(|(x, y, _)| !protected.contains(Position::new(*x, *y)) + && cursor != Some((*x, *y))) + ); + if width == 80 { + assert!(dots(&active).len() > 6); + assert!( + dots(&active) + .iter() + .chain(dots(&later).iter()) + .any(|(x, y, _)| *y == textarea.y && *x >= protected.right()) + ); + } + let (typed, _) = { + key(&mut pane, KeyCode::Char('x')); + draw(&pane.composer, width, now) + }; + assert_eq!( + pane.composer.cursor_pos(active.area), + cursor.map(|(x, y)| (x + 1, y)) + ); + assert!(!dots(&active).is_empty()); + assert!(dots(&typed).is_empty()); + snapshots.push(format!( + "{width} columns, cursor {cursor:?}\n{}\nfirst input:\n{}", + text(&active), + text(&typed) + )); + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn sparkle_fades_without_adding_dots_and_finishes_by_fifteen_seconds() { + palette(|| { + let now = Instant::now(); + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("astra", &enabled()); + draw(&pane.composer, /*width*/ 80, now); + let (last, _) = draw( + &pane.composer, + /*width*/ 80, + now + IDLE_TIMEOUT - IDLE_FADE, + ); + let (fading, _) = draw( + &pane.composer, + /*width*/ 80, + now + Duration::from_millis(/*millis*/ 14_500), + ); + let (finished, _) = draw(&pane.composer, /*width*/ 80, now + IDLE_TIMEOUT); + assert!(!dots(&fading).is_empty()); + assert!(dots(&fading).iter().all(|dot| dots(&last).contains(dot))); + let contrast = |cell: &ratatui::buffer::Cell| match (cell.fg, cell.bg) { + (Color::Rgb(fr, fg, fb), Color::Rgb(br, bg, bb)) => { + u32::from(fr.abs_diff(br)) + u32::from(fg.abs_diff(bg)) + u32::from(fb.abs_diff(bb)) + } + colors => panic!("expected true color: {colors:?}"), + }; + for (x, y, _) in dots(&fading) { + assert!(contrast(&fading[(x, y)]) < contrast(&last[(x, y)])); + } + assert!(dots(&finished).is_empty()); + pane.select_sparkle_model("astra", &enabled()); + assert!( + dots( + &draw( + &pane.composer, + /*width*/ 80, + now + IDLE_TIMEOUT + IDLE_FADE + ) + .0 + ) + .is_empty() + ); + insta::assert_snapshot!(format!( + "before fade:\n{}\nfading:\n{}\nfinished:\n{}", + text(&last), + text(&fading), + text(&finished) + )); + }); +} + +#[test] +fn the_first_buffered_character_and_empty_escape_clear_visible_stars() { + palette(|| { + let now = Instant::now(); + for input in [ + KeyCode::Char('x'), + KeyCode::Char('?'), + KeyCode::Esc, + KeyCode::Left, + ] { + let mut pane = pane(); + pane.composer.set_disable_paste_burst(/*disabled*/ false); + pane.mark_fresh_task_for_sparkle("astra", &enabled()); + assert!(!dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + key(&mut pane, input); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + assert!(pane.composer.current_text().is_empty()); + assert_eq!( + pane.composer.sparkle.draft.get(), + if input == KeyCode::Char('x') { + SparkleDraft::Dismissed + } else { + SparkleDraft::Untouched + } + ); + } + }); +} + +#[test] +fn shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not() { + palette(|| { + let now = Instant::now(); + let mut snapshots = Vec::new(); + for (scenario, input) in [ + ( + "unmodified", + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE), + ), + ( + "shifted", + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), + ), + ( + "remapped", + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ), + ( + "close", + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE), + ), + ] { + let mut pane = pane(); + if scenario == "remapped" { + pane.composer.toggle_shortcuts_keys = + vec![crate::key_hint::plain(KeyCode::Char('q'))]; + } + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + pane.handle_key_event(input); + if scenario == "close" { + pane.handle_key_event(input); + } + assert_eq!( + ( + pane.composer.footer.mode, + pane.composer.current_text(), + pane.composer.sparkle.draft.get() + ), + ( + if scenario == "close" { + super::super::FooterMode::ComposerEmpty + } else { + super::super::FooterMode::ShortcutOverlay + }, + String::new(), + SparkleDraft::Untouched + ), + "{scenario}" + ); + let help = draw(&pane.composer, /*width*/ 80, now).0; + assert!(dots(&help).is_empty()); + type_text(&mut pane, "/model"); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Model) + )); + pane.select_sparkle_model("gpt-6-astra", &enabled()); + let selected = draw(&pane.composer, /*width*/ 80, now).0; + assert!(!dots(&selected).is_empty(), "{scenario}"); + if scenario == "unmodified" { + snapshots.push(format!( + "shortcut help:\n{}\n\nafter /model selects Astra:\n{}", + text(&help), + text(&selected) + )); + } + } + + for scenario in ["paste", "remapped literal", "quit reminder"] { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + if scenario == "paste" { + pane.handle_paste("?".into()); + } else { + if scenario == "remapped literal" { + pane.composer.toggle_shortcuts_keys = + vec![crate::key_hint::plain(KeyCode::Char('q'))]; + } else { + pane.composer.show_quit_shortcut_hint( + crate::key_hint::ctrl(KeyCode::Char('c')), + /*has_focus*/ true, + ); + } + key(&mut pane, KeyCode::Char('?')); + } + assert_eq!( + ( + pane.composer.current_text(), + pane.composer.sparkle.draft.get() + ), + ("?".into(), SparkleDraft::Dismissed), + "{scenario}" + ); + let literal = draw(&pane.composer, /*width*/ 80, now).0; + key(&mut pane, KeyCode::Backspace); + type_text(&mut pane, "/model"); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Model) + )); + pane.select_sparkle_model("gpt-6-astra", &enabled()); + let selected = draw(&pane.composer, /*width*/ 80, now).0; + assert!(dots(&selected).is_empty(), "{scenario}"); + if scenario == "paste" { + snapshots.push(format!( + "literal pasted question mark:\n{}\n\nafter clearing and selecting Astra:\n{}", + text(&literal), + text(&selected) + )); + } + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn shortcut_help_hides_visible_sparkles_without_restarting_their_deadline() { + palette(|| { + let started = Instant::now(); + let during = started + Duration::from_secs(/*secs*/ 4); + let expired = started + IDLE_TIMEOUT; + let mut snapshots = Vec::new(); + for (scenario, input) in [ + ( + "unmodified", + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE), + ), + ( + "shifted", + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), + ), + ( + "remapped", + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ), + ] { + for close_at in [during, expired] { + let mut pane = pane(); + if scenario == "remapped" { + pane.composer.toggle_shortcuts_keys = + vec![crate::key_hint::plain(KeyCode::Char('q'))]; + } + pane.mark_fresh_task_for_sparkle("gpt-6-astra", &enabled()); + let active = draw(&pane.composer, /*width*/ 80, started).0; + let expected = draw(&pane.composer, /*width*/ 80, during).0; + assert!(!dots(&active).is_empty()); + assert!(!dots(&expected).is_empty()); + pane.handle_key_event(input); + assert!( + matches!(pane.composer.sparkle.phase.get(), Phase::Visible(at) if at == started) + ); + assert_eq!(pane.composer.current_text(), ""); + let help = draw(&pane.composer, /*width*/ 80, during).0; + assert!(dots(&help).is_empty(), "{scenario}"); + if close_at == expired { + let still_open = draw(&pane.composer, /*width*/ 80, expired).0; + assert!(dots(&still_open).is_empty()); + assert!(pane.composer.sparkle.phase.get() == Phase::Finished); + } + pane.handle_key_event(input); + let closed = draw(&pane.composer, /*width*/ 80, close_at).0; + assert_eq!( + dots(&closed), + if close_at == expired { + Vec::new() + } else { + dots(&expected) + }, + "{scenario}" + ); + if scenario == "unmodified" { + snapshots.push(format!( + "expires during help: {}\ninitial:\n{}\nshortcut help:\n{}\nafter closing:\n{}", + close_at == expired, + text(&active), + text(&help), + text(&closed) + )); + } + } + } + + let mut waiting = pane(); + waiting.mark_fresh_task_for_sparkle("gpt-6-astra", &enabled()); + key(&mut waiting, KeyCode::Char('?')); + let help = draw(&waiting.composer, /*width*/ 80, expired).0; + assert!(dots(&help).is_empty()); + assert!(waiting.composer.sparkle.phase.get() == Phase::Waiting); + key(&mut waiting, KeyCode::Char('?')); + assert!(!dots(&draw(&waiting.composer, /*width*/ 80, expired).0).is_empty()); + + let mut literal = pane(); + literal.mark_fresh_task_for_sparkle("gpt-6-astra", &enabled()); + assert!(!dots(&draw(&literal.composer, /*width*/ 80, started).0).is_empty()); + literal.handle_paste("?".into()); + assert_eq!(literal.composer.current_text(), "?"); + key(&mut literal, KeyCode::Backspace); + assert!(dots(&draw(&literal.composer, /*width*/ 80, during).0).is_empty()); + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn disconnected_sparkle_edits_are_tracked_between_renders() { + palette(|| { + let now = Instant::now(); + let mut snapshots = Vec::new(); + for model in ["gpt-6-astra", "gpt-5.5"] { + for (scenario, input, draft) in [ + ("ordinary", "x", SparkleDraft::Dismissed), + ("offline question mark", "?", SparkleDraft::Dismissed), + ("recognized command", "/status", SparkleDraft::Command), + ("unknown command", "/xyz", SparkleDraft::Dismissed), + ] { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle(model, &enabled()); + let initial = draw(&pane.composer, /*width*/ 80, now).0; + assert_eq!(dots(&initial).is_empty(), model == "gpt-5.5"); + for key in [KeyCode::Null, KeyCode::Enter, KeyCode::Tab] { + pane.handle_disconnected_key(KeyEvent::new(key, KeyModifiers::NONE)); + } + pane.handle_disconnected_key(KeyEvent::new_with_kind( + KeyCode::Char('x'), + KeyModifiers::NONE, + crossterm::event::KeyEventKind::Release, + )); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Untouched); + assert_eq!( + dots(&draw(&pane.composer, /*width*/ 80, now).0), + dots(&initial) + ); + for ch in input.chars() { + pane.handle_disconnected_key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + )); + } + assert_eq!(pane.composer.current_text(), input, "{scenario}"); + assert_eq!(pane.composer.sparkle.draft.get(), draft, "{scenario}"); + for _ in input.chars() { + pane.handle_disconnected_key(KeyEvent::new( + KeyCode::Backspace, + KeyModifiers::NONE, + )); + } + assert_eq!(pane.composer.current_text(), ""); + pane.select_sparkle_model("gpt-6-astra", &enabled()); + let after = draw(&pane.composer, /*width*/ 80, now).0; + assert_eq!( + dots(&after).is_empty(), + draft == SparkleDraft::Dismissed, + "{scenario}" + ); + if scenario == "ordinary" { + snapshots.push(format!( + "{model}\nbefore disconnect:\n{}\nafter typing and erasing without rendering:\n{}", + text(&initial), + text(&after) + )); + } + } + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn a_fresh_astra_response_waits_for_the_pending_command_to_leave() { + palette(|| { + let now = Instant::now(); + for completion in ["submit", "erase", "draft"] { + let mut pane = pane(); + type_text(&mut pane, "/status"); + pane.mark_fresh_task_for_sparkle("astra", &enabled()); + let pending = draw(&pane.composer, /*width*/ 80, now).0; + assert!(dots(&pending).is_empty()); + + match completion { + "submit" => assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Status) + )), + "erase" => { + for _ in "/status".chars() { + key(&mut pane, KeyCode::Backspace); + } + } + "draft" => { + type_text(&mut pane, "-draft"); + pane.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + } + _ => unreachable!(), + } + let completed = draw(&pane.composer, /*width*/ 80, now).0; + assert_eq!( + dots(&completed).is_empty(), + completion == "draft", + "{completion}" + ); + if completion == "submit" { + insta::assert_snapshot!(format!( + "command during startup:\n{}\n\nafter command:\n{}", + text(&pending), + text(&completed) + )); + } + } + }); +} + +#[test] +fn an_attachment_ends_the_sparkle_even_after_the_composer_is_cleared() { + palette(|| { + let now = Instant::now(); + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("astra", &enabled()); + assert!(!dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + pane.attach_image(crate::test_support::test_path_buf("/tmp/image.png")); + assert!(!pane.composer.is_empty()); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + pane.on_ctrl_c(); + assert!(pane.composer.is_empty()); + pane.select_sparkle_model("astra", &enabled()); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + }); +} + +#[test] +fn history_search_acceptance_and_cancellation_update_sparkles_before_rendering() { + palette(|| { + let now = Instant::now(); + let mut snapshots = Vec::new(); + for (entry, query) in [("git status", "git"), ("/status", "status")] { + for completion in ["accept", "escape", "control-c"] { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + pane.composer + .history + .record_local_submission(HistoryEntry::new(entry.into())); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + type_text(&mut pane, query); + assert!(pane.composer.history_search_active()); + assert_eq!(pane.composer.current_text(), entry); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Untouched); + + match completion { + "accept" => { + key(&mut pane, KeyCode::Enter); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Dismissed); + pane.on_ctrl_c(); + } + "escape" => { + key(&mut pane, KeyCode::Esc); + } + "control-c" => { + pane.on_ctrl_c(); + } + _ => unreachable!(), + } + assert!(!pane.composer.history_search_active()); + assert!(pane.composer.is_empty()); + pane.select_sparkle_model("astra", &enabled()); + let buffer = draw(&pane.composer, /*width*/ 80, now).0; + assert_eq!(dots(&buffer).is_empty(), completion == "accept"); + if entry == "git status" && completion != "control-c" { + snapshots.push(format!("{completion}:\n{}", text(&buffer))); + } + } + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn canceled_history_search_restores_visible_stars_only_before_the_original_deadline() { + palette(|| { + let started = Instant::now(); + let during = started + Duration::from_secs(/*secs*/ 4); + let expired = started + IDLE_TIMEOUT; + let mut snapshots = Vec::new(); + for (cancel, expires_while_open) in [ + ("escape", false), + ("control-c", false), + ("escape", true), + ("accept", false), + ] { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-6-astra", &enabled()); + pane.composer + .history + .record_local_submission(HistoryEntry::new("stored draft".into())); + let active = draw(&pane.composer, /*width*/ 80, started).0; + let expected = draw(&pane.composer, /*width*/ 80, during).0; + assert!(!dots(&active).is_empty()); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + type_text(&mut pane, "st"); + pane.handle_paste("ored".into()); + assert_eq!(pane.composer.current_text(), "stored draft"); + assert!(pane.composer.sparkle.phase.get() == Phase::Visible(started)); + let searching = draw(&pane.composer, /*width*/ 80, during).0; + assert!(dots(&searching).is_empty()); + if expires_while_open { + assert!(dots(&draw(&pane.composer, /*width*/ 80, expired).0).is_empty()); + } + + if cancel == "escape" { + key(&mut pane, KeyCode::Esc); + } else if cancel == "accept" { + key(&mut pane, KeyCode::Enter); + } else { + pane.on_ctrl_c(); + } + assert_eq!(pane.composer.is_empty(), cancel != "accept"); + let at = if expires_while_open { expired } else { during }; + let restored = draw(&pane.composer, /*width*/ 80, at).0; + assert_eq!( + dots(&restored), + if expires_while_open || cancel == "accept" { + Vec::new() + } else { + dots(&expected) + }, + "{cancel}, expires while open: {expires_while_open}", + ); + if cancel == "escape" { + snapshots.push(format!( + "expires while open: {expires_while_open}\ninitial:\n{}\nsearching:\n{}\nafter escape:\n{}", + text(&active), + text(&searching), + text(&restored), + )); + } + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn history_search_preserves_a_held_sparkle_command_without_reclassifying_it() { + palette(|| { + let now = Instant::now(); + let during = now + Duration::from_secs(/*secs*/ 4); + let mut snapshots = Vec::new(); + for model in ["gpt-6-astra", "gpt-5.5"] { + for cancel in ["escape", "control-c"] { + let mut pane = pane(); + pane.composer.set_disable_paste_burst(/*disabled*/ false); + pane.mark_fresh_task_for_sparkle(model, &enabled()); + pane.composer + .history + .record_local_submission(HistoryEntry::new("history prompt".into())); + let initial = draw(&pane.composer, /*width*/ 80, now).0; + let expected = draw(&pane.composer, /*width*/ 80, during).0; + assert_eq!(dots(&initial).is_empty(), model == "gpt-5.5"); + key(&mut pane, KeyCode::Char('/')); + assert!(pane.composer.is_in_paste_burst()); + assert_eq!(pane.composer.current_text(), ""); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Command); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(pane.composer.history_search_active()); + assert_eq!(pane.composer.current_text(), "/"); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Command); + pane.handle_paste("history".into()); + assert_eq!(pane.composer.current_text(), "history prompt"); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Command); + let searching = draw(&pane.composer, /*width*/ 80, during).0; + assert!(dots(&searching).is_empty()); + + if cancel == "escape" { + key(&mut pane, KeyCode::Esc); + } else { + pane.on_ctrl_c(); + } + assert_eq!(pane.composer.current_text(), "/"); + key(&mut pane, KeyCode::Backspace); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Untouched); + let erased = draw(&pane.composer, /*width*/ 80, during).0; + assert_eq!(dots(&erased), dots(&expected)); + type_text(&mut pane, "/model"); + pane.composer + .handle_paste_burst_flush(now + Duration::from_secs(/*secs*/ 1)); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Model) + )); + pane.select_sparkle_model("gpt-6-astra", &enabled()); + let selected = draw(&pane.composer, /*width*/ 80, during).0; + assert!(!dots(&selected).is_empty()); + if cancel == "escape" { + snapshots.push(format!("{model}\nsearching with an explicit pasted query:\n{}\nafter canceling and erasing the slash:\n{}\nafter /model selects Astra:\n{}", text(&searching), text(&erased), text(&selected))); + } + } + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn cancelling_history_search_preserves_a_typed_command_despite_attachment_previews() { + palette(|| { + let now = Instant::now(); + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + type_text(&mut pane, "/status"); + let mut entry = HistoryEntry::new("history attachment".into()); + entry.local_image_paths = vec![crate::test_support::test_path_buf("/tmp/image.png")]; + entry.remote_image_urls = vec!["https://example.com/image.png".into()]; + pane.composer.history.record_local_submission(entry); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + type_text(&mut pane, "history"); + assert!(!pane.composer.local_image_paths().is_empty()); + assert!(!pane.composer.remote_image_urls().is_empty()); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Command); + key(&mut pane, KeyCode::Esc); + assert_eq!(pane.composer.current_text(), "/status"); + assert!(pane.composer.local_image_paths().is_empty()); + assert!(pane.composer.remote_image_urls().is_empty()); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Status) + )); + pane.select_sparkle_model("astra", &enabled()); + assert!(!dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + }); +} + +#[test] +fn replacing_a_draft_outside_the_history_preview_dismisses_sparkles_during_search() { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(pane.composer.history_search_active()); + pane.composer + .set_text_content("external draft".into(), Vec::new(), Vec::new()); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Dismissed); + key(&mut pane, KeyCode::Esc); + assert!(pane.composer.is_empty()); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Dismissed); +} + +#[test] +fn arbitrary_commands_preserve_a_never_used_fresh_opportunity() { + palette(|| { + let now = Instant::now(); + for completion in [false, true] { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + for command in ["/status", "/diff", "/pwd"] { + type_text(&mut pane, command); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(_) + )); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Untouched); + } + type_text(&mut pane, if completion { "/mo" } else { "/model" }); + if completion { + key(&mut pane, KeyCode::Tab); + } + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Model) + )); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Untouched); + pane.select_sparkle_model("gpt-6-astra", &enabled()); + assert!(!dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + } + for input in ["hello", "/model extra", "/other", "漢", "/tmp/file"] { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + type_text(&mut pane, input); + pane.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + pane.select_sparkle_model("gpt-6-astra", &enabled()); + assert_eq!( + pane.composer.sparkle.draft.get(), + SparkleDraft::Dismissed, + "{input}" + ); + assert!( + dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty(), + "{input}" + ); + } + }); +} + +#[test] +fn canceled_commands_remain_eligible_but_inserted_content_and_ordinary_paste_do_not() { + palette(|| { + let now = Instant::now(); + let mut provisional = pane(); + type_text(&mut provisional, "x"); + key(&mut provisional, KeyCode::Backspace); + let snapshot = provisional.composer.draft_snapshot(); + assert!(snapshot.text.is_empty()); + let mut initialized = pane(); + initialized.mark_fresh_task_for_sparkle("astra", &enabled()); + initialized.inherit_startup_sparkle(snapshot.sparkle_draft); + assert!(dots(&draw(&initialized.composer, /*width*/ 80, now).0).is_empty()); + + let mut canceled = pane(); + canceled.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + type_text(&mut canceled, "/sta"); + for _ in 0..4 { + key(&mut canceled, KeyCode::Backspace); + } + assert_eq!( + canceled.composer.sparkle.draft.get(), + SparkleDraft::Untouched + ); + canceled.select_sparkle_model("astra", &enabled()); + assert!(!dots(&draw(&canceled.composer, /*width*/ 80, now).0).is_empty()); + + for input in ["paste draft", "inserted"] { + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + if input == "inserted" { + type_text(&mut pane, "/mention"); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Mention) + )); + pane.composer.insert_str("@"); + } else { + pane.handle_paste(input.to_string()); + } + pane.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + pane.select_sparkle_model("astra", &enabled()); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Dismissed); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + } + }); +} + +#[test] +fn review_regression_canceling_a_held_slash_restores_sparkle_command_tracking() { + palette(|| { + let now = Instant::now(); + let mut snapshots = Vec::new(); + for model in ["gpt-6-astra", "gpt-5.5"] { + let mut pane = pane(); + pane.composer.set_disable_paste_burst(/*disabled*/ false); + pane.mark_fresh_task_for_sparkle(model, &enabled()); + let initial = draw(&pane.composer, /*width*/ 80, now).0; + key(&mut pane, KeyCode::Char('/')); + assert!(pane.composer.is_in_paste_burst()); + assert_eq!(pane.composer.current_text(), ""); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Command); + key(&mut pane, KeyCode::Backspace); + assert!(!pane.composer.is_in_paste_burst()); + assert_eq!(pane.composer.current_text(), ""); + assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Untouched); + let canceled = draw(&pane.composer, /*width*/ 80, now).0; + assert_eq!(dots(&canceled), dots(&initial)); + + type_text(&mut pane, "/model"); + pane.composer + .handle_paste_burst_flush(now + Duration::from_secs(/*secs*/ 1)); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Model) + )); + pane.select_sparkle_model("gpt-6-astra", &enabled()); + let selected = draw(&pane.composer, /*width*/ 80, now).0; + assert!(!dots(&selected).is_empty()); + snapshots.push(format!( + "{model}\nafter immediately canceling the held slash:\n{}\nafter /model:\n{}", + text(&canceled), + text(&selected) + )); + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }); +} + +#[test] +fn buffered_commands_and_read_only_inline_arguments_preserve_eligibility() { + palette(|| { + let now = Instant::now(); + let mut buffered = pane(); + buffered + .composer + .set_disable_paste_burst(/*disabled*/ false); + buffered.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + type_text(&mut buffered, "/status"); + buffered + .composer + .handle_paste_burst_flush(now + Duration::from_secs(/*secs*/ 1)); + assert!(matches!( + key(&mut buffered, KeyCode::Enter), + InputResult::Command(SlashCommand::Status) + )); + assert_eq!( + buffered.composer.sparkle.draft.get(), + SparkleDraft::Untouched + ); + + let mut inline = pane(); + inline.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); + type_text(&mut inline, "/rename task name"); + assert_eq!(inline.composer.sparkle.draft.get(), SparkleDraft::Command); + assert!(matches!( + key(&mut inline, KeyCode::Enter), + InputResult::CommandWithArgs(SlashCommand::Rename, _, _) + )); + inline + .composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + inline.select_sparkle_model("astra", &enabled()); + assert!(!dots(&draw(&inline.composer, /*width*/ 80, now).0).is_empty()); + }); +} + +#[test] +fn commands_hide_a_started_sparkle_without_restarting_its_deadline() { + palette(|| { + let now = Instant::now(); + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("astra", &enabled()); + assert!(!dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + type_text(&mut pane, "/status"); + assert!( + dots( + &draw( + &pane.composer, + /*width*/ 80, + now + Duration::from_secs(/*secs*/ 5) + ) + .0 + ) + .is_empty() + ); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Status) + )); + assert!( + !dots( + &draw( + &pane.composer, + /*width*/ 80, + now + Duration::from_secs(/*secs*/ 6) + ) + .0 + ) + .is_empty() + ); + type_text(&mut pane, "/diff"); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now + IDLE_TIMEOUT).0).is_empty()); + assert!(matches!( + key(&mut pane, KeyCode::Enter), + InputResult::Command(SlashCommand::Diff) + )); + assert!( + dots( + &draw( + &pane.composer, + /*width*/ 80, + now + IDLE_TIMEOUT + IDLE_FADE + ) + .0 + ) + .is_empty() + ); + }); +} + +#[test] +fn focus_startup_work_and_configuration_do_not_extend_a_started_deadline() { + palette(|| { + let now = Instant::now(); + let mut pane = pane(); + pane.mark_fresh_task_for_sparkle("astra", &enabled()); + pane.composer.set_task_running(/*running*/ true); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + pane.composer.set_task_running(/*running*/ false); + assert!(!dots(&draw(&pane.composer, /*width*/ 80, now + IDLE_TIMEOUT).0).is_empty()); + pane.set_sparkle_terminal_focus(/*focused*/ false); + assert!( + dots( + &draw( + &pane.composer, + /*width*/ 80, + now + IDLE_TIMEOUT + Duration::from_secs(/*secs*/ 1) + ) + .0 + ) + .is_empty() + ); + pane.set_sparkle_terminal_focus(/*focused*/ true); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now + IDLE_TIMEOUT * 2).0).is_empty()); + + for settings in [ + Tui { + whimsy: false, + ..enabled() + }, + Tui { + animations: false, + ..enabled() + }, + ] { + let mut pane = super::tests::pane(); + pane.mark_fresh_task_for_sparkle("astra", &settings); + assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + } + let mut narrow = super::tests::pane(); + narrow.mark_fresh_task_for_sparkle("astra", &enabled()); + let (buffer, textarea) = draw(&narrow.composer, /*width*/ 25, now); + assert!( + dots(&buffer) + .iter() + .all(|(x, y, _)| !textarea.contains(Position::new(*x, *y))) + ); + }); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history.rs index 9cd4e2711001..cbe8b5a9611c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history.rs @@ -64,21 +64,21 @@ impl VimHistory { } impl ChatComposer { + pub(super) fn wants_vim_history_key(&self, event: KeyEvent) -> bool { + self.draft.textarea.is_vim_normal_mode() + && !self.draft.textarea.is_vim_operator_pending() + && !self.popups.active() + && (self.vim_normal_keymap.redo.is_pressed(event) + || self.vim_normal_keymap.undo.is_pressed(event)) + } + /// Consume undo and redo before normal-mode commands, even when their history is empty. pub(super) fn handle_vim_history_key(&mut self, event: KeyEvent) -> bool { - if !self.draft.textarea.is_vim_normal_mode() - || self.draft.textarea.is_vim_operator_pending() - || self.popups.active() - { + if !self.wants_vim_history_key(event) { return false; } let redo = self.vim_normal_keymap.redo.is_pressed(event); - let undo = self.vim_normal_keymap.undo.is_pressed(event); - if !undo && !redo { - return false; - } - let snapshot = if redo { self.vim_history.redo.pop_back() } else { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4dcfd3affb2c..6fa3681480a4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -382,6 +382,7 @@ mod model_popups; mod notifications; mod session_model_selection; use self::notifications::Notification; +pub(crate) use self::session_model_selection::AstraModelPickerAction; mod permission_discovery; mod permission_popups; mod permission_shortcuts; diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index c33af4511716..f6b702c49f8b 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -19,6 +19,8 @@ impl ChatWidget { /// Restore the exact draft entered before the fully initialized composer became available. pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) { + self.bottom_pane + .inherit_startup_sparkle(draft.sparkle_draft); let existing_draft = self.bottom_pane.composer_draft_snapshot(); let existing_cursor = existing_draft.cursor; let existing_message = UserMessage { @@ -106,6 +108,10 @@ impl ChatWidget { &mut self, pending_draft: &mut Option, ) { + if let Some(draft) = pending_draft.as_ref() { + self.bottom_pane + .inherit_startup_sparkle(draft.sparkle_draft); + } if self.has_active_view() || self .bottom_pane diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index d4567b6be9f8..211b66ed5410 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -134,6 +134,7 @@ impl ChatWidget { source: UserMessageSource, prepared_images: Option>, ) -> (bool, Option) { + self.bottom_pane.dismiss_composer_sparkle(); if self.has_misalignment_policy_violation() { return (false, None); } diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index fbb8f40e6e36..0f9ca10ec4e1 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -292,6 +292,7 @@ impl ChatWidget { .as_ref() .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); let thread_id = self.thread_id(); + let sparkle_thread = self.sparkle_thread_for_picker_action(&model_for_action); vec![Box::new(move |tx| { if model_for_action == LUNA_RESERVE_MODEL { // Reserve is temporary: update the active task without persisting a model default. @@ -302,17 +303,22 @@ impl ChatWidget { }); } } else if effort_for_action == Some(ReasoningEffortConfig::Ultra) { - tx.send(AppEvent::ApplyAdvancedReasoning { - model: model_for_action.clone(), - effort: ReasoningEffortConfig::Ultra, - }); + tx.send( + AstraModelPickerAction::ApplyAdvancedReasoning { + effort: ReasoningEffortConfig::Ultra, + } + .into_picker_event(sparkle_thread, model_for_action.clone()), + ); } else if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), effort: effort_for_action.clone(), }); } else { - tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send( + AstraModelPickerAction::UpdateModel + .into_picker_event(sparkle_thread, model_for_action.clone()), + ); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), @@ -394,13 +400,17 @@ impl ChatWidget { let warning = effort .as_ref() .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); + let sparkle_thread = self.sparkle_thread_for_picker_action(&model); let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); let effort = effort.clone(); let warning = warning.clone(); move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); + tx.send( + AstraModelPickerAction::UpdateModel + .into_picker_event(sparkle_thread, model.clone()), + ); tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); if let Some(warning) = warning.clone() { @@ -411,7 +421,10 @@ impl ChatWidget { } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); + tx.send( + AstraModelPickerAction::UpdateModel + .into_picker_event(sparkle_thread, model.clone()), + ); tx.send(AppEvent::UpdateReasoningEffort(effort.clone())); tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 72aea0090f5f..49e9f0bf0c36 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -102,6 +102,9 @@ impl ChatWidget { /// avoid triggering side effects. Event ids are passed as `None` to /// distinguish replayed events from live ones. pub(crate) fn replay_thread_turns(&mut self, turns: Vec, replay_kind: ReplayKind) { + if !turns.is_empty() || matches!(replay_kind, ReplayKind::ThreadSnapshot) { + self.bottom_pane.dismiss_composer_sparkle(); + } if matches!(replay_kind, ReplayKind::ThreadSnapshot) && !turns.is_empty() { self.warning_display_state.startup_complete = true; } diff --git a/codex-rs/tui/src/chatwidget/review_popups.rs b/codex-rs/tui/src/chatwidget/review_popups.rs index 7185e735b666..15edd4cff7af 100644 --- a/codex-rs/tui/src/chatwidget/review_popups.rs +++ b/codex-rs/tui/src/chatwidget/review_popups.rs @@ -4,6 +4,7 @@ use super::*; impl ChatWidget { pub(crate) fn on_review_started(&mut self) { + self.bottom_pane.dismiss_composer_sparkle(); self.bottom_pane.clear_pending_questions(); } diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 68d4b702b6d3..d930015e4d7a 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -1,8 +1,48 @@ //! Session configuration and thread-header orchestration for `ChatWidget`. +//! Confirmed model-picker changes can offer the flourish only on their original task; the app +//! checks the previous model when it applies the final selection. use super::*; impl ChatWidget { + /// Offer the flourish only for a successful primary thread/start without initial work. + pub(crate) fn mark_fresh_task_for_sparkle( + &mut self, + started: &crate::app_server_session::AppServerStartedThread, + ) { + if started.turns.is_empty() + && started.session.forked_from_id.is_none() + && !started.blocks_direct_input + && self.initial_user_message.is_none() + && !self.is_user_turn_pending_or_running() + { + self.bottom_pane + .mark_fresh_task_for_sparkle(&started.session.model, &self.local_settings.tui); + } else { + self.bottom_pane.dismiss_composer_sparkle(); + } + } + + pub(crate) fn set_sparkle_terminal_focus(&mut self, focused: bool) { + self.bottom_pane.set_sparkle_terminal_focus(focused); + } + + pub(crate) fn prepare_composer_sparkle_key(&self, key: KeyEvent) { + self.bottom_pane.prepare_composer_sparkle_key(key); + } + + pub(crate) fn sparkle_thread_for_picker_action(&self, model: &str) -> Option { + self.thread_id() + .filter(|_| BottomPane::is_sparkle_model(model)) + } + + pub(crate) fn on_sparkle_model_selected_from_picker(&mut self, model: &str) { + if self.current_model() == model { + self.bottom_pane + .select_sparkle_model(model, &self.local_settings.tui); + } + } + fn on_session_configured_with_display_and_fork_parent_title( &mut self, session: ThreadSessionState, diff --git a/codex-rs/tui/src/chatwidget/session_model_selection.rs b/codex-rs/tui/src/chatwidget/session_model_selection.rs index 6c4cee214f1a..20a362c32946 100644 --- a/codex-rs/tui/src/chatwidget/session_model_selection.rs +++ b/codex-rs/tui/src/chatwidget/session_model_selection.rs @@ -1,10 +1,46 @@ -//! Session-only model picker actions leave durable defaults untouched. +//! Session-only model picker actions leave durable defaults untouched. Astra picker actions retain +//! their originating task and are checked against its effective model when the app applies them. use super::*; use crate::bottom_pane::SelectionSecondaryAction; use crate::keymap::ListAction; use crate::model_catalog::LUNA_RESERVE_MODEL; +/// A final Astra selection whose model transition must be checked while the app applies it. +#[derive(Debug)] +pub(crate) enum AstraModelPickerAction { + UpdateModel, + ApplyAdvancedReasoning { + effort: ReasoningEffortConfig, + }, + SelectSessionModel { + effort: Option, + }, +} + +impl AstraModelPickerAction { + pub(super) fn into_picker_event(self, thread_id: Option, model: String) -> AppEvent { + match thread_id { + Some(thread_id) => AppEvent::AstraSelectedFromModelPicker { + thread_id, + model, + action: self, + }, + None => self.into_app_event(model), + } + } + + pub(crate) fn into_app_event(self, model: String) -> AppEvent { + match self { + Self::UpdateModel => AppEvent::UpdateModel(model), + Self::ApplyAdvancedReasoning { effort } => { + AppEvent::ApplyAdvancedReasoning { model, effort } + } + Self::SelectSessionModel { effort } => AppEvent::SelectSessionModel { model, effort }, + } + } +} + impl ChatWidget { pub(super) fn session_model_selection_action( &self, @@ -33,14 +69,17 @@ impl ChatWidget { let warning = effort .as_ref() .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); + let sparkle_thread = self.sparkle_thread_for_picker_action(&model); Some(SelectionSecondaryAction { key, footer_hint: hints.into(), action: Box::new(move |tx| { - tx.send(AppEvent::SelectSessionModel { - model: model.clone(), - effort: effort.clone(), - }); + tx.send( + AstraModelPickerAction::SelectSessionModel { + effort: effort.clone(), + } + .into_picker_event(sparkle_thread, model.clone()), + ); if let Some(warning) = warning.clone() { tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_warning_event(warning), diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 2f8bf9495e5f..1e20308abab2 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -415,6 +415,8 @@ impl ChatWidget { pub(super) fn refresh_model_display(&mut self) { let effective = self.effective_collaboration_mode(); + self.bottom_pane + .stop_ineligible_sparkle(effective.model(), &self.local_settings.tui); self.session_header.set_model(effective.model()); // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 9a68c6e61a2c..6d5ead9c5e9c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -276,6 +276,8 @@ mod review_mode; mod session_model_selection_tests; mod side; mod slash_commands; +#[path = "tests/sparkle_submission_tests.rs"] +mod sparkle_submission_tests; mod status_and_layout; mod status_command_tests; mod status_surface_previews; diff --git a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs index dd77afed7b68..38bf0165bd60 100644 --- a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs @@ -62,6 +62,56 @@ async fn session_model_selection_accepts_final_choices_without_saving() { } } +#[tokio::test] +async fn session_model_selection_notifies_the_original_task_after_each_final_astra_choice() { + for picker in ["model", "reasoning", "advanced"] { + let (mut chat, mut events, _ops) = make_chatwidget_manual(Some("gpt-5.5")).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + let mut preset = get_available_model(&chat, "gpt-5.5"); + preset.model = "gpt-6-astra".into(); + let effort = if picker == "advanced" { + ReasoningEffortConfig::Max + } else { + ReasoningEffortConfig::High + }; + preset.default_reasoning_effort = effort.clone(); + preset.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: effort.clone(), + description: "Selected effort".into(), + }]; + match picker { + "model" => chat.open_model_popup_with_presets(vec![preset]), + "reasoning" => { + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: ReasoningEffortConfig::Low, + description: "Low effort".into(), + }); + chat.open_reasoning_popup(preset); + } + "advanced" => chat.open_advanced_reasoning_popup(preset), + _ => unreachable!(), + } + while events.try_recv().is_ok() {} + chat.handle_key_event(KeyCode::Char('s').into()); + + assert_matches!(events.try_recv(), Ok(AppEvent::AstraSelectedFromModelPicker { + thread_id: selected_thread, + model, + action: AstraModelPickerAction::SelectSessionModel { effort: selected_effort }, + }) if selected_thread == thread_id && model == "gpt-6-astra" && selected_effort == Some(effort)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).all(|event| matches!( + event, + AppEvent::InsertHistoryCell(_) | AppEvent::SettingsSelectionClosed + )) + ); + assert!(chat.bottom_pane.no_modal_or_popup_active(), "{picker}"); + } +} + #[tokio::test] async fn session_model_selection_hides_conflicting_shortcut() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await; diff --git a/codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs b/codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs new file mode 100644 index 000000000000..01bbafae44d3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs @@ -0,0 +1,77 @@ +//! Covers sparkle dismissal across ordinary and prepared-image user submissions. + +use super::*; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use codex_app_server_protocol::ImageReference; +use pretty_assertions::assert_eq; +use pretty_assertions::assert_ne; + +#[tokio::test] +async fn ordinary_and_prepared_image_submissions_consume_the_sparkle_before_rendering() { + let image = UserInput::Image { + image: ImageReference::Inline { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lFkAAAAASUVORK5CYII=".into(), + }, + detail: None, + }; + let text = "describe"; + let visible = |chat: &ChatWidget| { + with_test_default_colors( + DefaultColors { + fg: (230, 216, 255), + bg: (36, 27, 53), + }, + || { + render_bottom_popup(chat, /*width*/ 80) + .chars() + .any(|ch| "⠁⠂⠄⠈⠐⠠⡀⢀".contains(ch)) + }, + ) + }; + + for prepared_images in [None, Some(vec![image])] { + let (mut chat, _events, mut ops) = make_chatwidget_manual(Some("gpt-6-astra")).await; + chat.thread_id = Some(ThreadId::new()); + chat.local_settings.tui.animations = true; + chat.local_settings.tui.whimsy = true; + chat.bottom_pane + .mark_fresh_task_for_sparkle("gpt-6-astra", &chat.local_settings.tui); + let untouched = chat.bottom_pane.composer_draft_snapshot().sparkle_draft; + assert_eq!(untouched, Default::default()); + assert!(visible(&chat)); + + let mut expected_items = prepared_images.clone().unwrap_or_default(); + expected_items.push(UserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }); + let message = UserMessage::from(text); + let (accepted, _command) = match prepared_images { + Some(images) => chat.submit_user_message_with_prepared_images( + message, + UserMessageHistoryRecord::UserMessageText, + ShellEscapePolicy::Disallow, + UserMessageSource::Prompt, + Some(images), + ), + None => chat.submit_user_message_with_history_and_shell_escape_policy( + message, + UserMessageHistoryRecord::UserMessageText, + ShellEscapePolicy::Disallow, + UserMessageSource::Prompt, + ), + }; + + assert!(accepted); + assert_ne!( + chat.bottom_pane.composer_draft_snapshot().sparkle_draft, + untouched + ); + assert!(!visible(&chat)); + let Op::UserTurn { items, .. } = next_submit_op(&mut ops) else { + unreachable!("next_submit_op only returns UserTurn") + }; + assert_eq!(items, expected_items); + } +} diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 6d7c32cecccd..412baa621951 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -74,6 +74,7 @@ impl ChatWidget { // Raw reasoning uses the same flow as summarized reasoning pub(super) fn on_task_started(&mut self) { + self.bottom_pane.dismiss_composer_sparkle(); self.clear_context_compaction(); self.input_queue.user_turn_pending_start = false; self.reset_safety_buffering_for_turn_start(); From 16f59db96eaa260eb2863974e4e6dea6bcca6bb3 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 03:47:34 +0000 Subject: [PATCH 017/373] Pause TUI events in the agents overview regression test (#46104) GitOrigin-RevId: d2ac8e172d4ee375e81c85c4fd11f764e9cfcc5d --- codex-rs/tui/src/app/tests/background_task_defaults_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index 8c1ec55dfc2c..4b22fe8da581 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -64,6 +64,7 @@ async fn review_regression_agents_overview_creation_is_fresh_but_returning_is_no app.harness_overrides.model = Some(model.into()); let mut server = start_config_write_test_app_server(&app).await?; let mut tui = make_test_tui()?; + tui.pause_events(); app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; let original = app.chat_widget.thread_id().expect("new dashboard task"); From 77c1feb00ed60a5540003dd48ce4df3598e13c9d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 04:05:59 +0000 Subject: [PATCH 018/373] Box app-server request handler futures to reduce stack usage (#46107) Use `Box::pin` when awaiting `handle_initialized_client_request` to keep queued requests small and avoid large stack temporaries during construction. GitOrigin-RevId: f3d1a6f9129f8b3a612cf054dbbb315ca61601dc --- codex-rs/app-server/src/message_processor.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6d9457157ca1..0aff37ce9008 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1019,15 +1019,15 @@ impl MessageProcessor { return; } let processor_for_request = Arc::clone(&processor); - let result = processor_for_request - .handle_initialized_client_request( - connection_request_id, - codex_request, - request_context, - session, - event_stream_ready, - ) - .await; + // Keep queued requests small to avoid large stack temporaries during construction. + let result = Box::pin(processor_for_request.handle_initialized_client_request( + connection_request_id, + codex_request, + request_context, + session, + event_stream_ready, + )) + .await; if let Err(error) = result { processor.outgoing.send_error(error_request_id, error).await; } From 108e6a6dbeed5485b3b732ed4a29c002780c8632 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Thu, 17 Sep 2026 04:09:26 +0000 Subject: [PATCH 019/373] Replace Sites migration state with a runtime compatibility guard (#46108) ## Why Older Desktop clients can still provide bundled Sites to an independently updated SSH app-server. A cached remote Sites install must take precedence, even when disabled, while a missing remote bundle must preserve the bundled fallback. ## What changed - Remove persisted bundled-plugin exclusions, Sites migration checks, and the migration wait when loading local plugin configuration. - Suppress `sites@openai-bundled` during plugin loading when the remote global catalog is active and a cached remote Sites install is available. - Remove exclusion-based catalog filtering and read/install guards, and simplify `install_plugin` to accept `ConfigLayerStack`. ## Testing Expand the agent-turn Sites test to cover enabled remote precedence, disabled remote suppression of bundled Sites, and fallback when the remote bundle is missing. GitOrigin-RevId: e2758596e5493ccee051cdfbf6b4afbe4f98948c --- .../src/request_processors/plugins.rs | 2 +- .../src/request_processors/plugins/local.rs | 18 +- .../app-server/tests/suite/v2/plugin_list.rs | 66 ---- .../tests/suite/v2/plugin_search.rs | 96 ------ codex-rs/cli/src/plugin_cmd.rs | 2 +- .../src/bundled_plugin_exclusions.rs | 201 ------------- .../core-plugins/src/discoverable_tests.rs | 2 +- codex-rs/core-plugins/src/loader.rs | 16 +- codex-rs/core-plugins/src/loader_tests.rs | 3 - codex-rs/core-plugins/src/manager.rs | 56 +--- codex-rs/core-plugins/src/manager_tests.rs | 284 +----------------- .../curated_loading_tests.rs | 2 +- .../handlers/request_plugin_install_tests.rs | 2 +- codex-rs/core/tests/suite/plugins.rs | 16 +- .../external-agent-migration/src/plugins.rs | 2 +- 15 files changed, 41 insertions(+), 727 deletions(-) delete mode 100644 codex-rs/core-plugins/src/bundled_plugin_exclusions.rs diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 770b808b75e5..ae98b0a32b7b 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1492,7 +1492,7 @@ impl PluginRequestProcessor { }; let result = match plugins_manager - .install_plugin(&config.plugins_config_input(), request) + .install_plugin(&config.config_layer_stack, request) .await { Ok(result) => result, diff --git a/codex-rs/app-server/src/request_processors/plugins/local.rs b/codex-rs/app-server/src/request_processors/plugins/local.rs index 79b236b1e0b1..67cbd5c4bbde 100644 --- a/codex-rs/app-server/src/request_processors/plugins/local.rs +++ b/codex-rs/app-server/src/request_processors/plugins/local.rs @@ -12,30 +12,14 @@ impl PluginRequestProcessor { &self, cwds: &[AbsolutePathBuf], ) -> Result { - let config = if cwds.is_empty() { + if cwds.is_empty() { self.config_manager .load_non_project_config() .await .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } else { self.load_latest_config(/*fallback_cwd*/ None).await - }?; - // TODO(sites-migration): Remove this initial migration wait after bundled Sites is retired. - let auth = self.auth_manager.auth().await; - match Box::pin( - self.thread_manager - .plugins_manager() - .ensure_sites_migration_ready(&config.plugins_config_input(), auth.as_ref()), - ) - .await - { - Ok(Some(change)) => (self.effective_plugins_changed_callback())(change), - Ok(None) => {} - Err(err) => { - warn!(error = %err, "Sites migration refresh failed; preserving local plugins") - } } - Ok(config) } pub(super) async fn load_marketplace_context( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 6db74de46e5c..65b79799a6b2 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -394,72 +394,6 @@ enabled = true Ok(()) } -#[tokio::test] -async fn plugin_installed_hides_bundled_sites_when_remote_sites_is_effective() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_curated_marketplace( - codex_home.path(), - "bundled_marketplace.json", - "openai-bundled", - Some("OpenAI Bundled"), - &["sites"], - )?; - write_installed_plugin(&codex_home, "openai-bundled", "sites")?; - write_installed_plugin(&codex_home, "openai-curated-remote", "sites")?; - std::fs::write( - codex_home.path().join("config.toml"), - format!( - r#"chatgpt_base_url = "{}/backend-api/" - -[features] -plugins = true -plugin_sharing = false - -[plugins."sites@openai-bundled"] -enabled = false -"#, - server.uri() - ), - )?; - write_remote_plugin_test_auth(codex_home.path())?; - - let mut remote_sites: serde_json::Value = serde_json::from_str(&remote_installed_plugin_body( - "", "1.2.3", /*enabled*/ false, - ))?; - remote_sites["plugins"][0]["id"] = - serde_json::json!("plugins~plugin_connector_1p_689987207de08191979cf68eca2941c6"); - remote_sites["plugins"][0]["name"] = serde_json::json!("sites"); - remote_sites["plugins"][0]["release"]["display_name"] = serde_json::json!("Sites"); - mount_remote_installed_plugins(&server, "GLOBAL", &serde_json::to_string(&remote_sites)?).await; - mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) - .await; - mount_empty_user_installed_plugins(&server).await; - let mut app_server = TestAppServer::builder() - .with_codex_home(codex_home.path()) - .build_initialized_with_timeout(DEFAULT_TIMEOUT) - .await?; - let request_id = app_server - .send_plugin_installed_request(PluginInstalledParams { - cwds: None, - install_suggestion_plugin_names: None, - }) - .await?; - let response: PluginInstalledResponse = - timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; - - assert_eq!( - response - .marketplaces - .iter() - .flat_map(|marketplace| &marketplace.plugins) - .map(|plugin| (plugin.id.as_str(), plugin.enabled)) - .collect::>(), - vec![("sites@openai-curated-remote", false)] - ); - Ok(()) -} - #[tokio::test] async fn plugin_installed_prefers_api_curated_conflicts_after_switching_to_api_auth() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/plugin_search.rs b/codex-rs/app-server/tests/suite/v2/plugin_search.rs index aaa34607c92c..1ca2dde2f2d7 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_search.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_search.rs @@ -421,102 +421,6 @@ async fn plugin_search_stitches_local_results_into_the_first_remote_page() -> Re Ok(()) } -#[tokio::test] -async fn plugin_search_hides_bundled_sites_when_remote_sites_is_effective() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - let curated_root = codex_home.path().join(".tmp/plugins"); - write_local_marketplace( - &curated_root, - "openai-bundled", - "bundled_marketplace.json", - &[LocalPluginFixture { - name: "sites", - display_name: "Sites", - keywords: &[], - description: "Build and deploy websites", - }], - )?; - write_installed_plugin(codex_home.path(), "openai-bundled", "sites")?; - write_installed_plugin(codex_home.path(), "openai-curated-remote", "sites")?; - std::fs::write( - codex_home.path().join("config.toml"), - format!( - r#"chatgpt_base_url = "{}/backend-api/" - -[features] -plugins = true -remote_plugin = true - -[plugins."sites@openai-bundled"] -enabled = false -"#, - server.uri() - ), - )?; - write_chatgpt_search_auth(codex_home.path())?; - - let remote_sites_backend_id = "plugins~plugin_connector_1p_689987207de08191979cf68eca2941c6"; - let mut remote_sites = remote_plugin_json( - remote_sites_backend_id, - "sites", - "GLOBAL", - /*discoverability*/ None, - ); - remote_sites["release"]["version"] = json!("1.2.3"); - remote_sites["enabled"] = json!(true); - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plugins": [remote_sites], - "pagination": {"next_page_token": null}, - }))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/search")) - .and(query_param("q", "sites")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plugins": [remote_plugin_json( - remote_sites_backend_id, - "sites", - "GLOBAL", - /*discoverability*/ None, - )], - "pagination": {"next_page_token": null}, - }))) - .expect(1) - .mount(&server) - .await; - - let mut app_server = TestAppServer::builder() - .with_codex_home(codex_home.path()) - .without_auto_env() - .build_initialized_with_timeout(DEFAULT_TIMEOUT) - .await?; - let request_id = app_server - .send_plugin_search_request(PluginSearchParams { - search_term: "sites".to_string(), - scope: None, - cwds: None, - cursor: None, - limit: None, - }) - .await?; - let response: PluginSearchResponse = - timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; - - assert_eq!( - response - .data - .iter() - .map(|result| result.plugin.id.as_str()) - .collect::>(), - vec!["sites@openai-curated-remote"] - ); - Ok(()) -} - #[tokio::test] async fn plugin_search_returns_local_matches_for_api_key_auth() -> Result<()> { for remote_plugin_enabled in [false, true] { diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index e82200b26f50..d2824526e73c 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -193,7 +193,7 @@ pub async fn run_plugin_add( context .manager .install_plugin( - &context.plugins_input, + &context.plugins_input.config_layer_stack, PluginInstallRequest { plugin_name: selection.plugin_name, marketplace_path: marketplace.path, diff --git a/codex-rs/core-plugins/src/bundled_plugin_exclusions.rs b/codex-rs/core-plugins/src/bundled_plugin_exclusions.rs deleted file mode 100644 index 3ad3b449d122..000000000000 --- a/codex-rs/core-plugins/src/bundled_plugin_exclusions.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Account/backend-scoped exclusions for bundled plugins replaced by remote plugins. -//! Only plugin IDs are persisted; remote installation and enablement remain server-owned. -// TODO(sites-migration): Remove this module when bundled Sites is retired and rollback support ends. - -use super::*; -use serde::Deserialize; -use serde::Serialize; -use sha2::Digest; -use sha2::Sha256; - -const BUNDLED_SITES_PLUGIN_ID: &str = "sites@openai-bundled"; -const REMOTE_SITES_PLUGIN_ID: &str = "sites@openai-curated-remote"; - -fn is_remote_sites(plugin: &RemoteInstalledPlugin) -> bool { - plugin.name == "sites" - && plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME - && plugin.id.strip_prefix("plugins~").unwrap_or(&plugin.id) - == "plugin_connector_1p_689987207de08191979cf68eca2941c6" -} - -#[derive(Default, Deserialize, Serialize)] -struct BundledPluginExclusions { - #[serde(rename = "disabled-bundled-plugin-ids")] - ids: BTreeSet, -} - -fn read_exclusions(path: &Path) -> BundledPluginExclusions { - std::fs::read(path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) - .unwrap_or_default() -} - -impl PluginsManager { - // Temporary direct-access guard using the same exclusion state as catalog/runtime loading. - pub(super) fn bundled_sites_is_hidden( - &self, - config: &PluginsConfigInput, - plugin_id: &str, - ) -> bool { - plugin_id == BUNDLED_SITES_PLUGIN_ID - && self.excluded_bundled_plugin_ids(config).contains(plugin_id) - } - - fn bundled_plugin_exclusion_path( - &self, - base_url: &str, - auth: Option<&CodexAuth>, - ) -> Option { - if self.restriction_product != Some(Product::Codex) { - return None; - } - let auth = auth.filter(|auth| auth.uses_codex_backend())?; - let account_id = auth.get_account_id().filter(|id| !id.is_empty())?; - let key = serde_json::to_vec(&( - base_url, - account_id, - auth.get_chatgpt_user_id(), - auth.is_workspace_account(), - )) - .ok()?; - let digest = Sha256::digest(key); - Some( - self.codex_home - .join("cache/bundled_plugin_exclusions") - .join(format!("{digest:x}.json")), - ) - } - - fn remote_sites_bundle_is_loadable(&self) -> bool { - PluginId::parse(REMOTE_SITES_PLUGIN_ID) - .ok() - .is_some_and(|id| self.store.active_plugin_root(&id).is_some()) - } - - pub(super) fn excluded_bundled_plugin_ids( - &self, - config: &PluginsConfigInput, - ) -> BTreeSet { - if !config.plugins_enabled || !self.remote_global_catalog_active(config) { - return BTreeSet::new(); - } - let auth = self.auth_manager.auth_cached(); - let Some(path) = - self.bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth.as_ref()) - else { - return BTreeSet::new(); - }; - let mut exclusions = read_exclusions(&path).ids; - // Preserve the bundled fallback if the replacement's local files are missing. - if !self.remote_sites_bundle_is_loadable() { - exclusions.remove(BUNDLED_SITES_PLUGIN_ID); - } - exclusions - } - - // Called only after a current, successful installed-snapshot fetch. Failed requests never - // erase the last known exclusion; successful absence restores the bundled fallback. - pub(super) fn update_sites_exclusion( - &self, - base_url: &str, - auth: Option<&CodexAuth>, - plugins: &[RemoteInstalledPlugin], - ) -> bool { - let Some(path) = self.bundled_plugin_exclusion_path(base_url, auth) else { - return false; - }; - let mut exclusions = read_exclusions(&path); - let remote_sites_ready = - plugins.iter().any(is_remote_sites) && self.remote_sites_bundle_is_loadable(); - let changed = if remote_sites_ready { - exclusions.ids.insert(BUNDLED_SITES_PLUGIN_ID.to_string()) - } else { - exclusions.ids.remove(BUNDLED_SITES_PLUGIN_ID) - }; - if changed { - let result = serde_json::to_string(&exclusions) - .map_err(std::io::Error::other) - .and_then(|contents| codex_utils_path::write_atomically(&path, &contents)); - if let Err(err) = result { - warn!(error = %err, "failed to persist bundled Sites exclusion"); - } - } - changed - } - - fn sites_migration_needed( - &self, - config: &PluginsConfigInput, - auth: Option<&CodexAuth>, - ) -> bool { - if !config.plugins_enabled - || !self.remote_global_catalog_active(config) - || self - .excluded_bundled_plugin_ids(config) - .contains(BUNDLED_SITES_PLUGIN_ID) - { - return false; - } - let Some(path) = self.bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth) else { - return false; - }; - !self - .sites_migration_checked_at - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .is_some_and(|(checked_path, checked_at)| { - checked_path == &path && checked_at.elapsed() < Duration::from_secs(60) - }) - } - - /// Coordinates the first Sites migration at the shared inventory boundary. A persisted - /// exclusion skips migration on restart without restoring any remote installed metadata. - pub async fn ensure_sites_migration_ready( - &self, - config: &PluginsConfigInput, - auth: Option<&CodexAuth>, - ) -> Result, RemoteInstalledPluginBundleSyncError> { - if !self.sites_migration_needed(config, auth) { - return Ok(None); - } - // Sites is implicitly installed by Plugin Service. Never create an explicit install or - // overwrite account-wide preferences from bundled config; /installed owns both decisions. - let plugins = crate::remote::fetch_remote_installed_plugins( - &remote_plugin_service_config(config), - auth, - ) - .await?; - let remote_sites_available = plugins.iter().any(is_remote_sites); - // A rollout-off check must not wait behind unrelated startup bundle downloads. - let _guard = if remote_sites_available { - let guard = self.acquire_remote_installed_plugin_sync_guard().await?; - // Another catalog request or background sync may have completed while we waited. - if !self.sites_migration_needed(config, auth) { - return Ok(None); - } - Some(guard) - } else { - None - }; - *self - .sites_migration_checked_at - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = self - .bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth) - .map(|path| (path, Instant::now())); - if !remote_sites_available { - return Ok(None); - } - let (outcome, changed) = - Box::pin(self.reconcile_remote_installed_plugins_after_acquiring_gate(config, auth)) - .await?; - // Preserve materialization details so the existing callback can trust eligible hooks. - Ok( - (changed || !outcome.changed_plugins.is_empty()).then_some(EffectivePluginsChange { - materialized_remote_plugins: outcome.materialized_remote_plugins, - }), - ) - } -} diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 53c3d5dbf6a2..63538ce9ba12 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -1081,7 +1081,7 @@ async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, let config = load_plugins_config(codex_home, marketplace_root).await; test_plugins_manager(codex_home.to_path_buf()) .install_plugin( - &config, + &config.config_layer_stack, PluginInstallRequest { plugin_name: plugin_name.to_string(), marketplace_path: AbsolutePathBuf::try_from( diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 4ba904023df3..d9f49eec4bf4 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -53,7 +53,6 @@ use codex_utils_plugins::SkillDiscoveryMode; use codex_utils_plugins::find_plugin_manifest_path; use codex_utils_plugins::migrated_command_skills_root; use serde_json::Value as JsonValue; -use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::fs; @@ -129,8 +128,6 @@ pub(crate) fn log_plugin_load_errors(plugins: &[LoadedPlugin]) } /// Load configured plugins without applying auth-dependent runtime policies. -// TODO(sites-migration): Remove the exclusion parameter and lint allowance when bundled Sites is retired. -#[allow(clippy::too_many_arguments)] #[instrument(level = "trace", skip_all)] pub(crate) async fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, @@ -140,7 +137,6 @@ pub(crate) async fn load_plugins_from_layer_stack( restriction_product: Option, remote_global_catalog_active: bool, skill_root_loader: &dyn SkillRootLoader, - excluded_plugin_ids: &BTreeSet, ) -> Vec> { let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); let RemoteInstalledPluginsSnapshot { @@ -150,7 +146,6 @@ pub(crate) async fn load_plugins_from_layer_stack( load_plugins_from_layer_stack_with_scope( config_layer_stack, extra_plugins, - excluded_plugin_ids, store, remote_global_catalog_active, PluginLoadScope::AllCapabilities { @@ -167,18 +162,16 @@ pub(crate) async fn load_plugins_from_layer_stack( async fn load_plugins_from_layer_stack_with_scope( config_layer_stack: &ConfigLayerStack, extra_plugins: HashMap, - excluded_plugin_ids: &BTreeSet, store: &PluginStore, remote_global_catalog_active: bool, scope: PluginLoadScope<'_>, ) -> Vec> { - let mut configured_plugins = merge_configured_plugins_with_remote_installed( + let configured_plugins = merge_configured_plugins_with_remote_installed( configured_plugins_from_stack(config_layer_stack, store.codex_home().as_path()), extra_plugins, store, remote_global_catalog_active, ); - configured_plugins.retain(|id, _| !excluded_plugin_ids.contains(id)); let mut configured_plugins: Vec<_> = configured_plugins.into_iter().collect(); configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); @@ -208,7 +201,6 @@ async fn load_plugins_from_layer_stack_with_scope( pub async fn load_plugin_hooks_from_layer_stack( config_layer_stack: &ConfigLayerStack, extra_plugins: HashMap, - excluded_plugin_ids: &BTreeSet, store: &PluginStore, target_curated_marketplace: TargetCuratedMarketplace, remote_global_catalog_active: bool, @@ -216,7 +208,6 @@ pub async fn load_plugin_hooks_from_layer_stack( let mut plugins = load_plugins_from_layer_stack_with_scope( config_layer_stack, extra_plugins, - excluded_plugin_ids, store, remote_global_catalog_active, PluginLoadScope::HooksOnly, @@ -246,6 +237,11 @@ fn merge_configured_plugins_with_remote_installed( remote_global_catalog_active: bool, ) -> HashMap { if remote_global_catalog_active { + // Older Desktop clients can still sync bundled Sites to an independently updated + // SSH app-server. A cached remote install takes precedence, even when disabled. + if extra_plugins.contains_key("sites@openai-curated-remote") { + configured_plugins.remove("sites@openai-bundled"); + } configured_plugins.retain(|plugin_key, _| match PluginId::parse(plugin_key) { Ok(plugin_id) => plugin_id.marketplace_name != crate::OPENAI_CURATED_MARKETPLACE_NAME, Err(_) => true, diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index d3b6a247dab8..894574d9dcdb 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -380,7 +380,6 @@ async fn installed_agent_plugin_uses_isolated_data_root_for_stdio_mcp() { Some(Product::Codex), /*remote_global_catalog_active*/ false, test_skill_root_loader().as_ref(), - &BTreeSet::new(), ) .await; @@ -591,13 +590,11 @@ enabled = true Some(Product::Codex), /*remote_global_catalog_active*/ false, test_skill_root_loader().as_ref(), - &BTreeSet::new(), ) .await; let hooks_only = load_plugins_from_layer_stack_with_scope( &stack, HashMap::new(), - &BTreeSet::new(), &store, /*remote_global_catalog_active*/ false, PluginLoadScope::HooksOnly, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index ab56913d9430..cce218323e2d 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1,5 +1,3 @@ -#[path = "bundled_plugin_exclusions.rs"] -mod bundled_plugin_exclusions; #[path = "remote_mutations.rs"] mod remote_mutations; pub use remote_mutations::RemotePluginInstallOutcome; @@ -540,8 +538,6 @@ pub struct PluginsManager { skill_root_loader: Arc>, tool_suggest_metadata_cache: ToolSuggestMetadataCache, remote_installed_plugins_cache: RwLock, - // TODO(sites-migration): Remove this throttle together with the bundled Sites migration. - sites_migration_checked_at: Mutex>, remote_installed_plugin_bundle_sync_gate: Arc, remote_installed_plugins_cache_refresh_state: RwLock, remote_catalog_cache_refresh_state: RwLock, @@ -597,8 +593,6 @@ struct PluginLoadCacheKey { skill_config_rules: SkillConfigRules, remote_global_catalog_active: bool, auth_identity: Option, - // TODO(sites-migration): Remove once bundled Sites no longer needs scoped exclusions. - excluded_plugin_ids: BTreeSet, } impl PluginLoadCacheKey { @@ -607,7 +601,6 @@ impl PluginLoadCacheKey { codex_home: &Path, remote_global_catalog_active: bool, auth_identity: RemoteInstalledPluginsAuthIdentity, - excluded_plugin_ids: BTreeSet, ) -> Self { Self { configured_plugins: configured_plugins_from_stack( @@ -618,7 +611,6 @@ impl PluginLoadCacheKey { remote_global_catalog_active, // Local curated loads are auth-independent; only remote snapshots vary by account. auth_identity: remote_global_catalog_active.then_some(auth_identity), - excluded_plugin_ids, } } } @@ -680,7 +672,6 @@ impl PluginsManager { skill_root_loader, tool_suggest_metadata_cache: ToolSuggestMetadataCache::new(), remote_installed_plugins_cache: RwLock::new(RemoteInstalledPluginsCache::default()), - sites_migration_checked_at: Mutex::new(None), remote_installed_plugin_bundle_sync_gate, remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), @@ -759,7 +750,6 @@ impl PluginsManager { self.codex_home.as_path(), self.remote_global_catalog_active(config), RemoteInstalledPluginsAuthIdentity::from_auth(auth.as_ref()), - self.excluded_bundled_plugin_ids(config), ); self.loaded_plugins_cache .lock() @@ -795,12 +785,11 @@ impl PluginsManager { let auth = self.auth_manager.auth_cached(); let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode); let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth.as_ref()); - let mut cache_key = PluginLoadCacheKey::from_config( + let cache_key = PluginLoadCacheKey::from_config( config, self.codex_home.as_path(), remote_global_catalog_active, auth_identity.clone(), - self.excluded_bundled_plugin_ids(config), ); if let Some(plugins) = self.cached_loaded_plugins(&cache_key) { let outcome = self.resolve_loaded_plugins_for_auth(plugins, auth_mode); @@ -833,8 +822,6 @@ impl PluginsManager { } continue; } - // Migration may have changed the effective exclusion while this load was queued. - cache_key.excluded_plugin_ids = self.excluded_bundled_plugin_ids(config); if let Some(plugins) = self.cached_loaded_plugins(&cache_key) { let outcome = self.resolve_loaded_plugins_for_auth(plugins, auth_mode); if *auth_change_receiver.borrow() == auth_revision { @@ -858,7 +845,6 @@ impl PluginsManager { self.restriction_product, remote_global_catalog_active, self.skill_root_loader.as_ref(), - &cache_key.excluded_plugin_ids, ) .await; loaded_cache_metrics::record_duration( @@ -975,7 +961,6 @@ impl PluginsManager { load_plugin_hooks_from_layer_stack( config_layer_stack, self.remote_installed_plugin_configs(), - &self.excluded_bundled_plugin_ids(config), &self.store, target_curated_marketplace, self.remote_global_catalog_active(config), @@ -1288,7 +1273,6 @@ impl PluginsManager { generation, plugins, auth, - &config.chatgpt_base_url, RemoteInstalledPluginsCachePublication::Refresh, ) else { let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth); @@ -1392,7 +1376,6 @@ impl PluginsManager { generation: u64, plugins: Vec, auth: Option<&CodexAuth>, - service_base_url: &str, publication: RemoteInstalledPluginsCachePublication, ) -> Option { let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth); @@ -1425,11 +1408,8 @@ impl PluginsManager { cache.reconciliation_generation = None; cache.generation = cache.generation.wrapping_add(1); } - // TODO(sites-migration): Remove exclusion publication once bundled Sites is retired. - let exclusion_changed = self.update_sites_exclusion(service_base_url, auth, &plugins); - let needs_effective_plugins_refresh = (is_reconcile - && std::mem::take(&mut cache.needs_effective_plugins_refresh)) - || exclusion_changed; + let needs_effective_plugins_refresh = + is_reconcile && std::mem::take(&mut cache.needs_effective_plugins_refresh); if cache.plugins.as_ref() == Some(&plugins) { drop(cache); if needs_effective_plugins_refresh { @@ -1453,7 +1433,6 @@ impl PluginsManager { generation, plugins, auth.as_ref(), - "", RemoteInstalledPluginsCachePublication::Refresh, ) .expect("test cache generation should remain current") @@ -1738,7 +1717,6 @@ impl PluginsManager { generation, result.installed_plugins, auth, - &config.chatgpt_base_url, RemoteInstalledPluginsCachePublication::Reconcile, ) else { return Err(RemoteInstalledPluginBundleSyncError::Superseded); @@ -2039,10 +2017,10 @@ impl PluginsManager { pub async fn install_plugin( &self, - config: &PluginsConfigInput, + config_layer_stack: &ConfigLayerStack, request: PluginInstallRequest, ) -> Result { - let resolved = self.resolve_installable_plugin(config, &request)?; + let resolved = self.resolve_installable_plugin(config_layer_stack, &request)?; let plugin_id = resolved.plugin_id.clone(); match self.install_resolved_plugin(resolved).await { Ok(outcome) => Ok(outcome), @@ -2060,7 +2038,7 @@ impl PluginsManager { fn resolve_installable_plugin( &self, - config: &PluginsConfigInput, + config_layer_stack: &ConfigLayerStack, request: &PluginInstallRequest, ) -> Result { let resolved = match find_installable_marketplace_plugin( @@ -2074,15 +2052,6 @@ impl PluginsManager { return Err(err.into()); } }; - if self.bundled_sites_is_hidden(config, &resolved.plugin_id.as_key()) { - let err = MarketplaceError::PluginNotFound { - plugin_name: resolved.plugin_id.plugin_name, - marketplace_name: resolved.plugin_id.marketplace_name, - }; - self.track_plugin_install_resolution_failed(&err); - return Err(err.into()); - } - let config_layer_stack = &config.config_layer_stack; if let Err(message) = MarketplacePolicy::from_requirements(config_layer_stack.requirements()) .validate_install( @@ -2108,7 +2077,7 @@ impl PluginsManager { auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { - let resolved = self.resolve_installable_plugin(config, &request)?; + let resolved = self.resolve_installable_plugin(&config.config_layer_stack, &request)?; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. if let Err(err) = crate::remote_legacy::enable_remote_plugin( @@ -2395,7 +2364,6 @@ impl PluginsManager { include_openai_curated: bool, plugin_states: &ConfiguredPluginStates, ) -> Result { - let excluded_plugin_ids = self.excluded_bundled_plugin_ids(config); let marketplace_roots = self.marketplace_roots(config, additional_roots, include_openai_curated); let marketplace_outcome = self.list_marketplaces_with_policy(config, &marketplace_roots)?; @@ -2410,9 +2378,6 @@ impl PluginsManager { .into_iter() .filter_map(|plugin| { let plugin_key = format!("{}@{marketplace_name}", plugin.name); - if excluded_plugin_ids.contains(&plugin_key) { - return None; - } if !seen_plugin_keys.insert(plugin_key.clone()) { return None; } @@ -2526,12 +2491,6 @@ impl PluginsManager { } let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; - if self.bundled_sites_is_hidden(config, &plugin.plugin_id.as_key()) { - return Err(MarketplaceError::PluginNotFound { - plugin_name: plugin.plugin_id.plugin_name, - marketplace_name: plugin.plugin_id.marketplace_name, - }); - } MarketplacePolicy::from_requirements(config.config_layer_stack.requirements()) .validate_install( &config.config_layer_stack, @@ -3347,7 +3306,6 @@ impl PluginsManager { request.generation, plugins, request.auth.as_ref(), - &request.service_config.chatgpt_base_url, RemoteInstalledPluginsCachePublication::Refresh, ) else { continue; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 26930a88ba07..c92c4fc68740 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -72,7 +72,6 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; -use wiremock::matchers::query_param_is_missing; const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; @@ -83,17 +82,6 @@ fn unrestricted_config_layer_stack() -> ConfigLayerStack { ConfigLayerStack::default() } -fn unrestricted_plugins_config_input() -> PluginsConfigInput { - PluginsConfigInput::new( - unrestricted_config_layer_stack(), - String::new(), - /*plugins_enabled*/ true, - /*remote_plugin_enabled*/ false, - String::new(), - test_http_client_factory(), - ) -} - fn config_layer_stack_with_requirements( codex_home: &Path, user_config: &str, @@ -3168,7 +3156,6 @@ fn loaded_plugins_cache_evicts_least_recently_used_configuration() { skill_config_rules: SkillConfigRules::default(), remote_global_catalog_active: false, auth_identity: None, - excluded_plugin_ids: BTreeSet::new(), }) .collect::>(); let generation = manager.loaded_plugins_cache_generation(); @@ -3213,7 +3200,6 @@ fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() { skill_config_rules: SkillConfigRules::default(), remote_global_catalog_active: false, auth_identity: None, - excluded_plugin_ids: BTreeSet::new(), }; let stale_generation = manager.loaded_plugins_cache_generation(); @@ -3352,7 +3338,7 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( - &unrestricted_plugins_config_input(), + &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "sample-plugin".to_string(), marketplace_path: AbsolutePathBuf::try_from( @@ -3410,7 +3396,7 @@ source = "local" path = {marketplace_root:?} "# ); - let config = plugins_config_input_with_requirements(codex_home.path(), "", &requirements); + let config = config_layer_stack_with_requirements(codex_home.path(), "", &requirements); let marketplace_path = AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json")) .expect("absolute marketplace path"); @@ -3442,7 +3428,7 @@ source = {marketplace_root:?} ); write_file(&codex_home.path().join(CONFIG_TOML_FILE), &user_config); let config = - plugins_config_input_with_requirements(codex_home.path(), &user_config, &requirements); + config_layer_stack_with_requirements(codex_home.path(), &user_config, &requirements); let outcome = manager .install_plugin( &config, @@ -3468,7 +3454,7 @@ async fn install_openai_curated_plugin_uses_short_sha_cache_version() { let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( - &unrestricted_plugins_config_input(), + &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "slack".to_string(), marketplace_path: AbsolutePathBuf::try_from( @@ -3529,7 +3515,7 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( - &unrestricted_plugins_config_input(), + &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "sample-plugin".to_string(), marketplace_path: AbsolutePathBuf::try_from( @@ -3597,7 +3583,7 @@ async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( - &unrestricted_plugins_config_input(), + &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "quality-review".to_string(), marketplace_path: AbsolutePathBuf::try_from( @@ -3697,7 +3683,7 @@ async fn install_plugin_supports_git_subdir_marketplace_sources() { let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( - &unrestricted_plugins_config_input(), + &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "toolkit".to_string(), marketplace_path: AbsolutePathBuf::try_from( @@ -3751,7 +3737,7 @@ async fn install_plugin_supports_relative_git_subdir_marketplace_sources() { let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( - &unrestricted_plugins_config_input(), + &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "toolkit".to_string(), marketplace_path: AbsolutePathBuf::try_from( @@ -7001,7 +6987,6 @@ async fn load_plugins_uses_project_config_files() { Some(Product::Codex), /*remote_global_catalog_active*/ false, test_skill_root_loader().as_ref(), - &BTreeSet::new(), ) .await; @@ -7289,255 +7274,6 @@ remote_plugin = true server.verify().await; } -#[tokio::test] -async fn sites_migration_throttles_absence_without_blocking_normal_sync() { - let home = TempDir::new().unwrap(); - write_file( - &home.path().join(CONFIG_TOML_FILE), - "[features]\nplugins = true\nremote_plugin = true\n", - ); - let server = MockServer::start().await; - let mut config = load_config(home.path(), home.path()).await; - config.chatgpt_base_url = format!("{}/backend-api", server.uri()); - let manager = test_plugins_manager_with_options( - home.path().to_path_buf(), - Some(Product::Codex), - Some(AuthMode::Chatgpt), - ); - let auth = manager.auth_manager.auth_cached().unwrap(); - let installed_path = "/backend-api/ps/plugins/installed"; - let failed_check = Mock::given(method("GET")) - .and(path(installed_path)) - .respond_with(ResponseTemplate::new(503)) - .mount_as_scoped(&server) - .await; - assert!( - manager - .ensure_sites_migration_ready(&config, Some(&auth)) - .await - .is_err() - ); - drop(failed_check); - Mock::given(method("GET")) - .and(path(installed_path)) - .and(query_param_is_missing("includeDownloadUrls")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "plugins": [], "pagination": {"next_page_token": null} - }))) - .expect(1) - .mount(&server) - .await; - // An unrelated startup download must not block a successful Sites-absence check. - let unrelated_sync = manager - .acquire_remote_installed_plugin_sync_guard() - .await - .unwrap(); - for _ in 0..2 { - assert!( - tokio::time::timeout( - Duration::from_secs(1), - manager.ensure_sites_migration_ready(&config, Some(&auth)), - ) - .await - .expect("Sites absence must not wait for unrelated bundle downloads") - .unwrap() - .is_none() - ); - } - drop(unrelated_sync); - Mock::given(method("GET")) - .and(path(installed_path)) - .and(query_param("includeDownloadUrls", "true")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "plugins": [], "pagination": {"next_page_token": null} - }))) - .expect(1) - .mount(&server) - .await; - manager - .reconcile_remote_installed_plugins(&config, Some(&auth)) - .await - .unwrap(); -} - -#[tokio::test] -async fn sites_migration_persists_only_exclusion_without_repeating_rollout() { - let home = TempDir::new().unwrap(); - let marketplace_root = home.path().join(".tmp/bundled-marketplaces/openai-bundled"); - write_file( - &home.path().join(CONFIG_TOML_FILE), - &format!( - "[features]\nplugins = true\nremote_plugin = true\n[plugins.\"sites@openai-bundled\"]\nenabled = true\n[marketplaces.openai-bundled]\nsource_type = \"local\"\nsource = {marketplace_root:?}\n" - ), - ); - write_cached_plugin(home.path(), "openai-bundled", "sites"); - write_plugin(&marketplace_root, "sites", "sites"); - write_file( - &marketplace_root.join(".agents/plugins/marketplace.json"), - r#"{"name":"openai-bundled","plugins":[{"name":"sites","source":{"source":"local","path":"./sites"}}]}"#, - ); - let server = MockServer::start().await; - let mut config = load_config(home.path(), home.path()).await; - config.chatgpt_base_url = format!("{}/backend-api", server.uri()); - let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); - let auth = auth_manager.auth_cached().unwrap(); - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "plugins": [{ - "id": "plugins~plugin_connector_1p_689987207de08191979cf68eca2941c6", - "name": "sites", "scope": "GLOBAL", "enabled": false, - "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", - "release": {"version": "local", "display_name": "Sites", "description": "Sites", "interface": {}} - }], - "pagination": {"next_page_token": null} - }))) - .mount(&server).await; - let manager = test_plugins_manager_with_auth_manager( - home.path().to_path_buf(), - Some(Product::Codex), - auth_manager.clone(), - ); - let has_bundled_sites = |manager: &PluginsManager| { - manager - .list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ false) - .unwrap() - .marketplaces - .into_iter() - .flat_map(|marketplace| marketplace.plugins) - .any(|plugin| plugin.id == "sites@openai-bundled") - }; - // An installed remote entry without local files must preserve the bundled runtime fallback. - manager - .reconcile_remote_installed_plugins(&config, Some(&auth)) - .await - .unwrap(); - assert!(has_bundled_sites(&manager)); - assert_eq!( - loaded_plugin_names(&manager, &config).await, - vec!["sites@openai-bundled"] - ); - write_cached_plugin(home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "sites"); - let read_request = PluginReadRequest { - plugin_name: "sites".to_string(), - marketplace_path: AbsolutePathBuf::try_from( - marketplace_root.join(".agents/plugins/marketplace.json"), - ) - .unwrap(), - }; - let install_request = PluginInstallRequest { - plugin_name: read_request.plugin_name.clone(), - marketplace_path: read_request.marketplace_path.clone(), - }; - let (first, concurrent) = tokio::join!( - manager.ensure_sites_migration_ready(&config, Some(&auth)), - manager.ensure_sites_migration_ready(&config, Some(&auth)), - ); - assert!(first.unwrap().is_some() ^ concurrent.unwrap().is_some()); - assert!(!manager.remote_installed_plugin_configs()["sites@openai-curated-remote"].enabled); - assert!( - server - .received_requests() - .await - .unwrap() - .iter() - .all(|request| request.method == "GET") - ); - let config_before_rejected_install = fs::read(home.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(matches!( - manager.read_plugin_for_config(&config, &read_request).await, - Err(MarketplaceError::PluginNotFound { plugin_name, marketplace_name }) - if plugin_name == "sites" && marketplace_name == "openai-bundled" - )); - assert!(matches!( - manager.install_plugin(&config, install_request.clone()).await, - Err(PluginInstallError::Marketplace(MarketplaceError::PluginNotFound { plugin_name, marketplace_name })) - if plugin_name == "sites" && marketplace_name == "openai-bundled" - )); - assert_eq!( - fs::read(home.path().join(CONFIG_TOML_FILE)).unwrap(), - config_before_rejected_install - ); - let exclusion_file = std::fs::read_dir(home.path().join("cache/bundled_plugin_exclusions")) - .unwrap() - .next() - .unwrap() - .unwrap() - .path(); - assert_eq!( - serde_json::from_slice::(&std::fs::read(exclusion_file).unwrap()) - .unwrap(), - serde_json::json!({"disabled-bundled-plugin-ids": ["sites@openai-bundled"]}) - ); - let requests = server.received_requests().await.unwrap().len(); - let restarted = test_plugins_manager_with_auth_manager( - home.path().to_path_buf(), - Some(Product::Codex), - auth_manager.clone(), - ); - assert!( - restarted - .ensure_sites_migration_ready(&config, Some(&auth)) - .await - .unwrap() - .is_none() - ); - assert_eq!(server.received_requests().await.unwrap().len(), requests); - assert!(!has_bundled_sites(&restarted)); - assert!(restarted.remote_installed_plugin_configs().is_empty()); - assert!( - !loaded_plugin_names(&restarted, &config) - .await - .contains(&"sites@openai-bundled".to_string()) - ); - - // A warm runtime/skill cache must not carry this backend's exclusion into another backend. - let mut other_backend = config.clone(); - other_backend.chatgpt_base_url = format!("{}/other-backend", server.uri()); - assert!( - restarted - .excluded_bundled_plugin_ids(&other_backend) - .is_empty() - ); - assert!( - restarted - .plugin_skill_snapshots_for_config(&config) - .is_some() - ); - assert!( - restarted - .plugin_skill_snapshots_for_config(&other_backend) - .is_none() - ); - assert_eq!( - loaded_plugin_names(&restarted, &other_backend).await, - vec!["sites@openai-bundled"] - ); - assert!(loaded_plugin_names(&restarted, &config).await.is_empty()); - - server.reset().await; - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "plugins": [], "pagination": {"next_page_token": null} - }))) - .mount(&server) - .await; - restarted - .reconcile_remote_installed_plugins(&config, Some(&auth)) - .await - .unwrap(); - assert!(has_bundled_sites(&restarted)); - restarted - .read_plugin_for_config(&config, &read_request) - .await - .unwrap(); - restarted - .install_plugin(&config, install_request) - .await - .unwrap(); -} - #[tokio::test] async fn reconcile_remote_installed_plugins_reports_cached_state_changes() { let codex_home = TempDir::new().unwrap(); @@ -7797,7 +7533,6 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() { stale_refresh_generation, Vec::new(), /*auth*/ None, - "", RemoteInstalledPluginsCachePublication::Refresh, ), None @@ -7807,7 +7542,6 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() { reconcile_generation, vec![remote_installed_linear_plugin()], /*auth*/ None, - "", RemoteInstalledPluginsCachePublication::Reconcile, ), Some(true) @@ -7831,7 +7565,6 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() { refresh_generation, vec![remote_installed_plugin("beta")], /*auth*/ None, - "", RemoteInstalledPluginsCachePublication::Refresh, ), Some(true) @@ -7844,7 +7577,6 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() { retry_generation, vec![remote_installed_plugin("beta")], /*auth*/ None, - "", RemoteInstalledPluginsCachePublication::Reconcile, ), Some(true) diff --git a/codex-rs/core-plugins/src/marketplace_policy/curated_loading_tests.rs b/codex-rs/core-plugins/src/marketplace_policy/curated_loading_tests.rs index 64186f08452d..841ebf1d87e9 100644 --- a/codex-rs/core-plugins/src/marketplace_policy/curated_loading_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_policy/curated_loading_tests.rs @@ -77,7 +77,7 @@ host_pattern = '^github[.]com$' assert_eq!( manager .install_plugin( - config, + &config.config_layer_stack, PluginInstallRequest { plugin_name: "sample".to_string(), marketplace_path: AbsolutePathBuf::try_from(manifest.clone()).unwrap(), diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs index 9d1b310934f5..b2f6c7b477e2 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs @@ -113,7 +113,7 @@ async fn verified_plugin_install_completed_requires_installed_plugin() { plugins_manager .install_plugin( - &config.plugins_config_input(), + &config.config_layer_stack, PluginInstallRequest { plugin_name: "sample".to_string(), marketplace_path: AbsolutePathBuf::try_from( diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index f93f0f6b3ea5..b868d1620f22 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -838,8 +838,15 @@ async fn legacy_plugin_skill_prompt_remains_complete() -> Result<()> { Ok(()) } +#[test_case(true, true, (true, false); "enabled remote supersedes bundled")] +#[test_case(false, true, (false, false); "disabled remote does not reactivate bundled")] +#[test_case(true, false, (false, true); "missing remote bundle preserves bundled")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sites_migration_agent_turn_prefers_loadable_remote_sites() -> Result<()> { +async fn sites_compatibility_guard_in_agent_turn( + remote_enabled: bool, + cache_remote_sites: bool, + expected_skills: (bool, bool), +) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let response = mount_sse_once( @@ -863,7 +870,7 @@ async fn sites_migration_agent_turn_prefers_loadable_remote_sites() -> Result<() "description": "Sites", "interface": {}, }, - "enabled": true, + "enabled": remote_enabled, }], "pagination": {"next_page_token": null}, }))) @@ -885,6 +892,9 @@ enabled = true ("openai-bundled", "bundled-sites"), ("openai-curated-remote", "remote-sites"), ] { + if marketplace == "openai-curated-remote" && !cache_remote_sites { + continue; + } let root = codex_home .path() .join(format!("plugins/cache/{marketplace}/sites/local")); @@ -933,7 +943,7 @@ enabled = true developer_text.contains("sites:remote-sites"), developer_text.contains("sites:bundled-sites"), ), - (true, false), + expected_skills, "unexpected Sites skills in developer prompt: {developer_text:?}" ); Ok(()) diff --git a/codex-rs/external-agent-migration/src/plugins.rs b/codex-rs/external-agent-migration/src/plugins.rs index f61c071d4d66..26932c4278cd 100644 --- a/codex-rs/external-agent-migration/src/plugins.rs +++ b/codex-rs/external-agent-migration/src/plugins.rs @@ -223,7 +223,7 @@ impl ExternalAgentConfigService { for plugin_name in plugin_names { match plugins_manager .install_plugin( - &install_config.plugins_config_input(), + &install_config.config_layer_stack, PluginInstallRequest { plugin_name: plugin_name.clone(), marketplace_path: marketplace_path.clone(), From 841b5490b2517a8d86a1ead7bd9790f11176e363 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 17 Sep 2026 04:28:44 +0000 Subject: [PATCH 020/373] Preserve filesystem sandbox policy context when the cwd disappears (#46112) ## Why Removing the selected working directory can prevent filesystem sandbox helpers from launching, even when the requested absolute paths remain accessible. Permission rules must stay anchored to the selected directory while those operations continue. ## What changed - Require a policy `cwd` in `FileSystemSandboxContext` and launch filesystem helpers from the filesystem root while preserving the policy directory and workspace roots. - Carry explicit `policyContext` in filesystem RPCs, preserving legacy wire fields and resolving omitted directories from older clients at executor ingress. - Keep permission paths as executor file URIs and validate host compatibility where they are enforced. - Bind Windows relative denial globs to the policy directory before changing the helper's launch directory, preserving home-relative patterns. ## Testing Add regression coverage for `apply_patch` after working-directory removal, legacy RPC directory fallbacks, cross-platform permission URI transport, and Windows relative read denials. The patch regression verifies that an allowed file is updated while an explicitly denied file remains unreadable and unchanged. GitOrigin-RevId: b0f4db722b27cb72ec129fc297c85732afac11f7 --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/apply_patch_tests.rs | 2 +- codex-rs/core/src/environment_selection.rs | 6 +- codex-rs/core/src/session/mod.rs | 26 +- codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/src/session/turn_context.rs | 4 +- .../core/src/tools/handlers/apply_patch.rs | 7 +- .../src/tools/handlers/extension_tools.rs | 4 +- codex-rs/core/src/tools/handlers/mod.rs | 12 +- .../src/tools/handlers/request_permissions.rs | 6 +- .../handlers/unified_exec/exec_command.rs | 9 +- .../core/src/tools/runtimes/apply_patch.rs | 4 +- .../src/tools/runtimes/apply_patch_tests.rs | 13 +- codex-rs/core/src/tools/sandboxing.rs | 4 +- codex-rs/core/src/tools/sandboxing_tests.rs | 4 +- .../src/unified_exec/process_manager_tests.rs | 5 +- .../core/src/unified_exec/shell_snapshot.rs | 4 +- .../core/src/unified_exec/stdin_approval.rs | 7 +- .../src/unified_exec/stdin_approval_tests.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 146 ++++ codex-rs/core/tests/suite/remote_env.rs | 12 +- codex-rs/exec-server-protocol/src/protocol.rs | 405 +++++++++- codex-rs/exec-server/BUILD.bazel | 2 + codex-rs/exec-server/Cargo.toml | 1 + .../exec-server/src/capability_discovery.rs | 7 + codex-rs/exec-server/src/client.rs | 52 +- codex-rs/exec-server/src/environment.rs | 4 +- codex-rs/exec-server/src/fs_sandbox.rs | 243 ++++-- codex-rs/exec-server/src/local_file_system.rs | 13 +- codex-rs/exec-server/src/process_sandbox.rs | 15 +- .../exec-server/src/process_sandbox_tests.rs | 51 +- .../exec-server/src/remote_file_stream.rs | 4 +- .../exec-server/src/remote_file_system.rs | 21 +- .../src/remote_file_system_path_uri_tests.rs | 322 +++++++- .../sandboxed_file_system_path_uri_tests.rs | 36 +- .../src/server/file_system_handler.rs | 5 +- codex-rs/exec-server/src/server/registry.rs | 89 ++- .../exec-server/tests/capability_discovery.rs | 733 +++++++++++++++++- codex-rs/exec-server/tests/exec_process.rs | 14 +- .../tests/exec_process/windows_sandbox.rs | 2 +- codex-rs/exec-server/tests/file_stream.rs | 6 +- .../exec-server/tests/file_system/shared.rs | 14 +- .../exec-server/tests/file_system/support.rs | 21 +- .../exec-server/tests/file_system_unix.rs | 4 +- .../exec-server/tests/file_system_windows.rs | 120 +++ .../tests/executor_file_system_authority.rs | 6 +- .../src/exec_permission_profile_serde.rs | 22 + codex-rs/file-system/src/lib.rs | 252 ++++-- 48 files changed, 2419 insertions(+), 326 deletions(-) create mode 100644 codex-rs/file-system/src/exec_permission_profile_serde.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 77470f0ae150..86413b6d6064 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3263,6 +3263,7 @@ dependencies = [ "codex-shell-command", "codex-test-binary-support", "codex-utils-absolute-path", + "codex-utils-cargo-bin", "codex-utils-home-dir", "codex-utils-path-uri", "codex-utils-pty", diff --git a/codex-rs/core/src/apply_patch_tests.rs b/codex-rs/core/src/apply_patch_tests.rs index 275b2a2da740..45e33822f21c 100644 --- a/codex-rs/core/src/apply_patch_tests.rs +++ b/codex-rs/core/src/apply_patch_tests.rs @@ -59,7 +59,7 @@ async fn prepare_apply_patch_uses_action_policy_before_turn_policy() { environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(permission_profile); let sandbox = environment.sandbox_context(/*additional_permissions*/ None); - let context = sandbox.policy_context().expect("local sandbox context"); + let context = sandbox.policy_context(); let prepared = prepare_apply_patch( &step, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index c1993ab5fc99..62761030bfb9 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -1333,8 +1333,7 @@ url = "ws://127.0.0.1:8765" assert_eq!( environment .sandbox_context(/*additional_permissions*/ None) - .policy_context() - .expect("selected environment sandbox context has cwd"), + .policy_context(), FileSystemSandboxPolicyContext { cwd: environment.cwd(), workspace_roots: &[], @@ -1686,8 +1685,7 @@ url = "ws://127.0.0.1:8765" assert_eq!( environment .sandbox_context(/*additional_permissions*/ None) - .policy_context() - .expect("selected environment sandbox context has cwd"), + .policy_context(), FileSystemSandboxPolicyContext { cwd: environment.cwd(), workspace_roots: &[], diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c242c83cd4d6..c5df08eee156 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3055,13 +3055,7 @@ impl Session { let requested_permissions = args.permissions; let sandbox_context = environment.sandbox_context(/*additional_permissions*/ None); - let Some(context) = sandbox_context.policy_context() else { - return Some(RequestPermissionsResponse { - permissions: RequestPermissionProfile::default(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - }); - }; + let context = sandbox_context.policy_context(); { let originating_turn_state = { let active = self.active_turn.lock().await; @@ -3292,19 +3286,11 @@ impl Session { let sandbox_context = entry .environment .sandbox_context(/*additional_permissions*/ None); - let response = if let Some(context) = sandbox_context.policy_context() { - Self::normalize_request_permissions_response( - entry.requested_permissions, - response, - &context, - ) - } else { - RequestPermissionsResponse { - permissions: RequestPermissionProfile::default(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - } - }; + let response = Self::normalize_request_permissions_response( + entry.requested_permissions, + response, + &sandbox_context.policy_context(), + ); self.record_granted_request_permissions_for_turn( &response, &entry.environment.selection.environment_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 723f85a88210..0bea18376cfe 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9062,8 +9062,8 @@ async fn capability_discovery_uses_environment_permission_profile() { environment_config.windows_sandbox_private_desktop = false; environment_config.use_legacy_landlock = true; let expected_sandbox = FileSystemSandboxContext { - permissions: environment.permission_profile().clone().into(), - cwd: Some(environment.cwd().clone()), + permissions: environment.permission_profile().clone(), + cwd: environment.cwd().clone(), workspace_roots: environment.workspace_roots().to_vec(), user_home_dir: environment.user_home_dir.clone(), temporary_directories: environment.temporary_directories.clone(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index ae88a809ae73..13a68466f3e6 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -222,8 +222,8 @@ impl TurnEnvironment { additional_permissions.as_ref(), ); FileSystemSandboxContext { - permissions: permissions.into(), - cwd: Some(self.cwd().clone()), + permissions, + cwd: self.cwd().clone(), workspace_roots: self.workspace_roots().to_vec(), user_home_dir: self.user_home_dir.clone(), temporary_directories: self.temporary_directories.clone(), diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 5ab666628304..527c8770fcdd 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -552,12 +552,7 @@ async fn execute_verified_patch( ) -> Result { let cwd = action.cwd.clone(); let sandbox_context = turn_environment.sandbox_context(/*additional_permissions*/ None); - let Some(policy_context) = file_system_sandbox_policy_context_for_cwd(&sandbox_context, &cwd) - else { - return Err(FunctionCallError::RespondToModel( - "apply_patch requires an executor cwd".to_string(), - )); - }; + let policy_context = file_system_sandbox_policy_context_for_cwd(&sandbox_context, &cwd); let sandbox_route = if turn_environment.environment.is_remote() { PatchSandboxRoute::ExecutorManaged } else { diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 92ac4bdb81c5..0fcb6165c240 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -299,7 +299,7 @@ mod tests { struct CapturingExtensionExecutor { captured_call: Arc>>>, - captured_sandbox_cwds: Arc>>>, + captured_sandbox_cwds: Arc>>, } impl<'call> codex_extension_api::ToolExecutor> @@ -432,7 +432,7 @@ mod tests { let expected_sandbox_cwds = turn .environments .turn_environments() - .map(|environment| Some(environment.cwd().clone())) + .map(|environment| environment.cwd().clone()) .collect::>(); let history_item = ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index ce09df5b3339..38cc41c7482e 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -243,10 +243,10 @@ pub(super) struct EffectiveAdditionalPermissions { pub(super) fn file_system_sandbox_policy_context_for_cwd<'a>( sandbox_context: &'a FileSystemSandboxContext, cwd: &'a PathUri, -) -> Option> { - let mut context = sandbox_context.policy_context()?; +) -> codex_protocol::permissions::FileSystemSandboxPolicyContext<'a> { + let mut context = sandbox_context.policy_context(); context.cwd = cwd; - Some(context) + context } pub(super) fn implicit_granted_permissions( @@ -298,9 +298,9 @@ pub(super) async fn apply_granted_turn_permissions( if additional_permissions.is_none() { Some(granted.clone()) } else { - effective_permissions.as_ref().and_then(|effective| { - preapproved_permission_profile(effective, granted, context.as_ref()?) - }) + effective_permissions + .as_ref() + .and_then(|effective| preapproved_permission_profile(effective, granted, &context)) } }); let permissions_preapproved = preapproved_permissions.is_some(); diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index bb3a7de75d23..ececd52c6d04 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -79,11 +79,7 @@ impl RequestPermissionsHandler { }; let sandbox_context = turn_environment.sandbox_context(/*additional_permissions*/ None); - let context = sandbox_context.policy_context().ok_or_else(|| { - FunctionCallError::RespondToModel( - "request_permissions requires an executor cwd".to_string(), - ) - })?; + let context = sandbox_context.policy_context(); let mut arguments: Value = parse_arguments(&arguments)?; resolve_permission_path_strings(&mut arguments, &context)?; let mut args: RequestPermissionsArgs = diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 0a026fbb6de4..0e43be83184f 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -322,14 +322,7 @@ impl ExecCommandHandler { let requested_additional_permissions = additional_permissions.clone(); let sandbox_context = turn_environment.sandbox_context(/*additional_permissions*/ None); - let Some(permission_context) = - file_system_sandbox_policy_context_for_cwd(&sandbox_context, &cwd) - else { - manager.release_process_id(process_id).await; - return Err(FunctionCallError::RespondToModel( - "selected environment sandbox context is missing cwd".to_string(), - )); - }; + let permission_context = file_system_sandbox_policy_context_for_cwd(&sandbox_context, &cwd); let effective_additional_permissions = apply_granted_turn_permissions( context.session.as_ref(), turn_environment, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 435883b4b16b..776c0fffe8fd 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -97,8 +97,8 @@ impl ApplyPatchRuntime { req.additional_permissions.as_ref(), ); Some(FileSystemSandboxContext { - permissions: permissions.into(), - cwd: Some(attempt.sandbox_cwd.clone()), + permissions, + cwd: attempt.sandbox_cwd.clone(), workspace_roots: attempt.workspace_roots.to_vec(), user_home_dir: req.turn_environment.user_home_dir.clone(), temporary_directories: None, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 85a76226d5b2..be3080d10598 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -265,15 +265,10 @@ async fn file_system_sandbox_context_preserves_executor_workspace_permissions() ); let expected_permissions = PermissionProfile::from_runtime_permissions(&file_system_policy, network_policy); - let native_permissions: PermissionProfile = sandbox - .permissions - .clone() - .try_into() - .expect("native sandbox permissions"); - assert_eq!(native_permissions, expected_permissions); + assert_eq!(sandbox.permissions, expected_permissions); assert_eq!( sandbox.cwd, - Some(codex_utils_path_uri::PathUri::from_abs_path(&path)) + codex_utils_path_uri::PathUri::from_abs_path(&path) ); assert_eq!( sandbox.windows_sandbox_selection, @@ -348,8 +343,8 @@ async fn file_system_sandbox_context_respects_sandbox_request() { assert_eq!( ApplyPatchRuntime::file_system_sandbox_context_for_attempt(&req, &attempt), Some(FileSystemSandboxContext { - permissions: permissions.into(), - cwd: Some(cwd.clone()), + permissions, + cwd: cwd.clone(), workspace_roots: vec![cwd], user_home_dir: Some(user_home_dir), temporary_directories: None, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 1bcce42afd82..1c8201dde2ef 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -520,8 +520,8 @@ impl<'a> SandboxAttempt<'a> { exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { - permissions: exec_server_permissions.into(), - cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), + permissions: exec_server_permissions, + cwd: exec_request.windows_sandbox_policy_cwd.clone(), workspace_roots: self.workspace_roots.to_vec(), user_home_dir: None, temporary_directories: None, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index ee146a1ebbd4..392d62e7e753 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -368,8 +368,8 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { assert_eq!( request.exec_server_sandbox, Some(codex_exec_server::FileSystemSandboxContext { - permissions: exec_server_permissions.clone().into(), - cwd: Some(cwd_uri.clone()), + permissions: exec_server_permissions.clone(), + cwd: cwd_uri.clone(), workspace_roots: vec![cwd_uri.clone()], user_home_dir: None, temporary_directories: None, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 8be6eda33742..bb928ad8f9c8 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -271,7 +271,10 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { request.exec_server_shell_snapshot = None; request.exec_server_sandbox = Some( - codex_exec_server::FileSystemSandboxContext::from_permission_profile(permission_profile), + codex_exec_server::FileSystemSandboxContext::from_permission_profile( + permission_profile, + request.cwd.clone(), + ), ); let first = params_for_request(&request); let second = params_for_request(&request); diff --git a/codex-rs/core/src/unified_exec/shell_snapshot.rs b/codex-rs/core/src/unified_exec/shell_snapshot.rs index 941266f5d22b..343c89df03bc 100644 --- a/codex-rs/core/src/unified_exec/shell_snapshot.rs +++ b/codex-rs/core/src/unified_exec/shell_snapshot.rs @@ -92,8 +92,8 @@ impl Session { /*has_managed_network_requirements*/ false, ) .then(|| FileSystemSandboxContext { - permissions: environment.permission_profile().clone().into(), - cwd: Some(environment.cwd().clone()), + permissions: environment.permission_profile().clone(), + cwd: environment.cwd().clone(), workspace_roots: environment.workspace_roots().to_vec(), user_home_dir: None, temporary_directories: None, diff --git a/codex-rs/core/src/unified_exec/stdin_approval.rs b/codex-rs/core/src/unified_exec/stdin_approval.rs index 750fd3f954d1..75172d173e3e 100644 --- a/codex-rs/core/src/unified_exec/stdin_approval.rs +++ b/codex-rs/core/src/unified_exec/stdin_approval.rs @@ -12,7 +12,6 @@ use crate::session::turn_context::TurnEnvironment; use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ToolError; use codex_features::Feature; -use codex_file_system::ExecPermissionProfile; use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::EnvironmentNetworkPolicy; use codex_protocol::models::AdditionalPermissionProfile; @@ -69,7 +68,7 @@ impl TerminalPolicy { fn file_system_context(&self) -> FileSystemSandboxContext { let mut context = self.sandbox.clone(); // Network changes require review, not rejection for denied-read drift. - if let ExecPermissionProfile::Managed { network, .. } = &mut context.permissions { + if let PermissionProfile::Managed { network, .. } = &mut context.permissions { *network = NetworkSandboxPolicy::Restricted; } context @@ -131,7 +130,7 @@ impl TerminalPermissions { Ok(if bypassed || &self.policy != current { SandboxPermissions::RequireEscalated } else if self.policy.sandbox.permissions - == effective_permission_profile(baseline, /*additional_permissions*/ None).into() + == effective_permission_profile(baseline, /*additional_permissions*/ None) { SandboxPermissions::UseDefault } else { @@ -145,7 +144,7 @@ impl TerminalPermissions { ) -> Result { let authority = if self.launch_permissions.requires_escalated_permissions() { "This terminal was launched outside the sandbox, bypassing any managed network proxy." - } else if self.policy.sandbox.permissions == ExecPermissionProfile::Disabled { + } else if self.policy.sandbox.permissions == PermissionProfile::Disabled { "This terminal runs without a filesystem sandbox." } else { match sandbox_permissions { diff --git a/codex-rs/core/src/unified_exec/stdin_approval_tests.rs b/codex-rs/core/src/unified_exec/stdin_approval_tests.rs index 8f0a2b38c263..5d06ebcf99e9 100644 --- a/codex-rs/core/src/unified_exec/stdin_approval_tests.rs +++ b/codex-rs/core/src/unified_exec/stdin_approval_tests.rs @@ -20,6 +20,7 @@ fn terminal_permissions(profile: &PermissionProfile) -> TerminalPermissions { policy: TerminalPolicy { sandbox: FileSystemSandboxContext::from_permission_profile( effective_permission_profile(profile, /*additional_permissions*/ None), + PathUri::from_host_native_path(std::env::temp_dir()).expect("local temporary cwd"), ), environment_network: None, controller_network: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index b5dda68de7bb..75de4ed1da43 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -43,6 +43,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; #[cfg(target_os = "linux")] use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; @@ -939,6 +940,151 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox() -> Result<()> Ok(()) } +/// An intercepted update can verify allowed absolute paths after the turn cwd disappears, +/// while the same turn still cannot read or update an explicitly denied target. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn intercepted_apply_patch_updates_absolute_target_after_turn_cwd_is_removed() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_target_windows!( + Ok(()), + "the default Windows restricted-token test backend cannot enforce denied reads" + ); + + let harness = apply_patch_harness_with(|builder| builder.with_model("gpt-5.4")).await?; + let test = harness.test(); + let workspace = test.workspace_path_uri("")?; + let original_cwd = test.workspace_path_uri("policy-cwd")?; + let allowed_target = test.workspace_path_uri("allowed.txt")?; + let denied_target = test.workspace_path_uri("denied.txt")?; + harness.create_dir_all("policy-cwd").await?; + harness.write_file("allowed.txt", "original\n").await?; + harness.write_file("denied.txt", "private\n").await?; + + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: workspace.clone(), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_target.clone(), + }, + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }, + ]); + let permissions = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permissions, test.config.cwd.as_path()); + let selection = TurnEnvironmentSelection { + cwd: original_cwd, + workspace_roots: vec![workspace.clone()], + ..test.executor_environment().selection().clone() + }; + + let workdir = workspace.inferred_native_path_string(); + let allowed_patch = format!( + "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: {}\n@@\n-original\n+changed\n*** End Patch\nEOF\n", + allowed_target.inferred_native_path_string(), + ); + let denied_patch = format!( + "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: {}\n@@\n-private\n+changed\n*** End Patch\nEOF\n", + denied_target.inferred_native_path_string(), + ); + let responses = mount_sse_sequence( + harness.server(), + vec![ + sse(vec![ + ev_response_created("resp-remove"), + ev_exec_command_call_with_args( + "remove-cwd", + &json!({"cmd": "rmdir policy-cwd", "workdir": workdir, "login": false}), + ), + ev_completed("resp-remove"), + ]), + sse(vec![ + ev_response_created("resp-allowed"), + ev_exec_command_call_with_args( + "patch-allowed", + &json!({"cmd": allowed_patch, "workdir": workdir, "login": false}), + ), + ev_completed("resp-allowed"), + ]), + sse(vec![ + ev_response_created("resp-denied"), + ev_exec_command_call_with_args( + "patch-denied", + &json!({"cmd": denied_patch, "workdir": workdir, "login": false}), + ), + ev_completed("resp-denied"), + ]), + sse(vec![ + ev_assistant_message("msg-done", "done"), + ev_completed("resp-done"), + ]), + ], + ) + .await; + + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "remove the turn cwd, then update the allowed and denied files".into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some(TurnEnvironmentSelections::new( + test.config.cwd.clone(), + vec![selection], + )), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + ..Default::default() + }), + ) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert!(!harness.path_exists("policy-cwd").await?); + let allowed_output = responses + .function_call_output_text("patch-allowed") + .expect("the model should receive the allowed patch output"); + assert!(allowed_output.contains("Success."), "{allowed_output}"); + let denied_output = responses + .function_call_output_text("patch-denied") + .expect("the model should receive the denied patch output"); + assert!( + denied_output.contains("apply_patch verification failed") + && denied_output.contains("Failed to read"), + "{denied_output}", + ); + assert_eq!( + ( + harness.read_file_text("allowed.txt").await?, + harness.read_file_text("denied.txt").await?, + ), + ("changed\n".to_string(), "private\n".to_string()), + ); + Ok(()) +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn escalated_patch_rejects_symlink_swapped_after_approval_request() -> Result<()> { diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index f3052334508b..e5295233debf 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -2940,7 +2940,8 @@ fn absolute_path(path: PathBuf) -> AbsolutePathBuf { fn read_only_sandbox(readable_root: PathBuf) -> FileSystemSandboxContext { let readable_root = absolute_path(readable_root); - FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( + let cwd = PathUri::from_abs_path(&readable_root); + let permissions = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: readable_root.into(), @@ -2949,12 +2950,14 @@ fn read_only_sandbox(readable_root: PathBuf) -> FileSystemSandboxContext { missing_path_behavior: None, }]), NetworkSandboxPolicy::Restricted, - )) + ); + FileSystemSandboxContext::from_permission_profile(permissions, cwd) } fn workspace_write_sandbox(writable_root: PathBuf) -> FileSystemSandboxContext { let writable_root = absolute_path(writable_root); - FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( + let cwd = PathUri::from_abs_path(&writable_root); + let permissions = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: writable_root.into(), @@ -2963,7 +2966,8 @@ fn workspace_write_sandbox(writable_root: PathBuf) -> FileSystemSandboxContext { missing_path_behavior: None, }]), NetworkSandboxPolicy::Restricted, - )) + ); + FileSystemSandboxContext::from_permission_profile(permissions, cwd) } fn assert_normalized_path_rejected(error: &std::io::Error) { diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 67125ab0c28c..fc9060f564c0 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -5,6 +5,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_file_system::FileSystemSandboxContext; pub use codex_file_system::WalkOptions; pub use codex_file_system::WalkOutcome; +use codex_file_system::WireFileSystemSandboxContext; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::RemoteNetworkProxyLaunchConfig; use codex_protocol::ThreadId; @@ -15,6 +16,7 @@ use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; +use crate::JSONRPCErrorError; use crate::ProcessId; pub const INITIALIZE_METHOD: &str = "initialize"; @@ -328,6 +330,70 @@ pub struct ExecParams { pub network_proxy: Option, } +/// Executor ingress for process requests from clients that may omit the sandbox policy cwd. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireExecParams { + process_id: ProcessId, + metadata: Option, + argv: Vec, + cwd: PathUri, + env_policy: Option, + shell_snapshot: Option, + env: HashMap, + tty: bool, + #[serde(default)] + pipe_stdin: bool, + arg0: Option, + sandbox: Option, + #[serde(default)] + enforce_managed_network: bool, + managed_network: Option, + network_proxy: Option, +} + +impl From for ExecParams { + fn from(wire: WireExecParams) -> Self { + let WireExecParams { + process_id, + metadata, + argv, + cwd, + env_policy, + shell_snapshot, + env, + tty, + pipe_stdin, + arg0, + sandbox, + enforce_managed_network, + managed_network, + network_proxy, + } = wire; + let sandbox = sandbox.map(|sandbox| { + // Legacy processes used the required process cwd as their sandbox policy cwd. + let policy_cwd = sandbox.cwd().unwrap_or(&cwd).clone(); + sandbox.into_context(policy_cwd) + }); + Self { + process_id, + metadata, + argv, + cwd, + env_policy, + shell_snapshot, + env, + tty, + pipe_stdin, + arg0, + sandbox, + enforce_managed_network, + managed_network, + network_proxy, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecEnvPolicy { @@ -461,6 +527,16 @@ pub struct FsReadFileParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsReadFileParams { + path: PathUri, + #[serde(default, skip_serializing_if = "Option::is_none")] + follow_symlinks: Option, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsReadFileResponse { @@ -475,6 +551,15 @@ pub struct FsOpenParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsOpenParams { + handle_id: String, + path: PathUri, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsOpenResponse { @@ -516,6 +601,17 @@ pub struct FsWriteFileParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsWriteFileParams { + path: PathUri, + data_base64: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + follow_symlinks: Option, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsWriteFileResponse {} @@ -530,6 +626,17 @@ pub struct FsCreateDirectoryParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsCreateDirectoryParams { + path: PathUri, + recursive: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + follow_symlinks: Option, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsCreateDirectoryResponse {} @@ -543,6 +650,16 @@ pub struct FsGetMetadataParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsGetMetadataParams { + path: PathUri, + #[serde(default, skip_serializing_if = "Option::is_none")] + follow_symlinks: Option, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsGetMetadataResponse { @@ -561,6 +678,14 @@ pub struct FsCanonicalizeParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsCanonicalizeParams { + path: PathUri, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsCanonicalizeResponse { @@ -574,6 +699,14 @@ pub struct FsReadDirectoryParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsReadDirectoryParams { + path: PathUri, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsReadDirectoryEntry { @@ -596,6 +729,15 @@ pub struct FsWalkParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsWalkParams { + path: PathUri, + options: WalkOptions, + sandbox: Option, +} + pub type FsWalkResponse = WalkOutcome; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -609,6 +751,18 @@ pub struct FsRemoveParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsRemoveParams { + path: PathUri, + recursive: Option, + force: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + follow_symlinks: Option, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsRemoveResponse {} @@ -622,6 +776,16 @@ pub struct FsCopyParams { pub sandbox: Option, } +/// Filesystem RPC wire request with legacy optional sandbox policy cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFsCopyParams { + source_path: PathUri, + destination_path: PathUri, + recursive: bool, + sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsCopyResponse {} @@ -633,6 +797,13 @@ pub struct CapabilityRootsDiscoverParams { pub roots: Vec, } +/// Executor ingress for capability roots whose sandbox policy cwd may be absent. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireCapabilityRootsDiscoverParams { + roots: Vec, +} + /// One caller-selected capability root. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -646,6 +817,84 @@ pub struct CapabilityRootDiscoverRequest { pub sandbox: Option, } +/// One executor-ingress capability root with legacy optional sandbox policy cwd. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireCapabilityRootDiscoverRequest { + id: String, + path: PathUri, + sandbox: Option, +} + +// Destructuring both types makes adding a request field require updating the wire conversion. +macro_rules! impl_wire_filesystem_request { + ($($wire:ident => $request:ident { $($field:ident),+ $(,)? }),+ $(,)?) => {$( + impl From<$request> for $wire { + fn from(request: $request) -> Self { + let $request { $($field,)+ sandbox } = request; + Self { $($field,)+ sandbox: sandbox.map(Into::into) } + } + } + + impl $wire { + /// Resolves optional wire sandbox intent before it reaches an executor handler. + pub fn try_into_request( + self, + resolve: fn(WireFileSystemSandboxContext) -> Result, + ) -> Result<$request, JSONRPCErrorError> { + let Self { $($field,)+ sandbox } = self; + Ok($request { $($field,)+ sandbox: sandbox.map(resolve).transpose()? }) + } + } + )+}; +} + +impl_wire_filesystem_request! { + WireFsReadFileParams => FsReadFileParams { path, follow_symlinks }, + WireFsOpenParams => FsOpenParams { handle_id, path }, + WireFsWriteFileParams => FsWriteFileParams { path, data_base64, follow_symlinks }, + WireFsCreateDirectoryParams => FsCreateDirectoryParams { path, recursive, follow_symlinks }, + WireFsGetMetadataParams => FsGetMetadataParams { path, follow_symlinks }, + WireFsCanonicalizeParams => FsCanonicalizeParams { path }, + WireFsReadDirectoryParams => FsReadDirectoryParams { path }, + WireFsWalkParams => FsWalkParams { path, options }, + WireFsRemoveParams => FsRemoveParams { path, recursive, force, follow_symlinks }, + WireFsCopyParams => FsCopyParams { source_path, destination_path, recursive }, +} + +impl WireCapabilityRootDiscoverRequest { + /// Resolves optional wire sandbox intent before it reaches an executor handler. + pub fn try_into_request( + self, + resolve: fn( + WireFileSystemSandboxContext, + ) -> Result, + ) -> Result { + let Self { id, path, sandbox } = self; + Ok(CapabilityRootDiscoverRequest { + id, + path, + sandbox: sandbox.map(resolve).transpose()?, + }) + } +} + +impl WireCapabilityRootsDiscoverParams { + /// Resolves each root's optional wire sandbox before it reaches an executor handler. + pub fn try_into_request( + self, + resolve: fn( + WireFileSystemSandboxContext, + ) -> Result, + ) -> Result { + let mut roots = Vec::with_capacity(self.roots.len()); + for root in self.roots { + roots.push(root.try_into_request(resolve)?); + } + Ok(CapabilityRootsDiscoverParams { roots }) + } +} + /// Executor-local discovery results in request order. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -908,17 +1157,21 @@ mod base64_bytes { #[cfg(test)] mod tests { + use super::CapabilityRootDiscoverRequest; use super::EnvironmentCapabilities; use super::EnvironmentInfo; use super::ExecExitedNotification; use super::ExecMetadata; use super::ExecParams; use super::ExecResponse; + use super::FsOpenParams; use super::FsReadFileParams; use super::HttpRequestParams; use super::ProcessId; use super::ProcessSandboxType; use super::ShellInfo; + use super::WireFsOpenParams; + use super::WireFsReadFileParams; use codex_file_system::FileSystemSandboxContext; use codex_file_system::WindowsSandboxSelection; use codex_network_proxy::ManagedNetworkSandboxContext; @@ -1222,7 +1475,7 @@ mod tests { })) .expect_err("native absolute path should not deserialize as a URI"); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::default(), PathUri::from_host_native_path(&native_cwd).expect("cwd URI"), ); @@ -1273,13 +1526,14 @@ mod tests { network: NetworkSandboxPolicy::Restricted, }; let mut sandbox = - FileSystemSandboxContext::from_permission_profile_with_cwd(permissions, cwd.clone()); + FileSystemSandboxContext::from_permission_profile(permissions, cwd.clone()); sandbox.user_home_dir = Some(cwd.clone()); sandbox.windows_sandbox_selection = WindowsSandboxSelection::Mxc; let serialized = serde_json::to_value(&sandbox).expect("serialize sandbox"); assert_eq!(serialized["windowsSandboxLevel"], "mxc"); + assert_eq!(serialized["cwd"], serde_json::json!(cwd.to_string())); assert_eq!( serialized["userHomeDir"], serde_json::json!(cwd.to_string()) @@ -1324,6 +1578,150 @@ mod tests { ); } + /// The filesystem RPC preserves Windows drive and UNC permission URIs on any host. + #[test] + fn filesystem_protocol_preserves_foreign_permission_path_json() { + let cwd = PathUri::parse("file:///C:/a%20b").expect("drive cwd URI"); + let unc = PathUri::parse("file://host/s/a%20b").expect("UNC path URI"); + let permissions = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { + entries: [cwd.clone(), unc.clone()] + .into_iter() + .map(|path| { + FileSystemSandboxEntry::new(path.into(), FileSystemAccessMode::Read) + }) + .collect(), + glob_scan_max_depth: None, + }, + network: NetworkSandboxPolicy::Restricted, + }; + let params = FsReadFileParams { + path: unc, + follow_symlinks: None, + sandbox: Some(FileSystemSandboxContext::from_permission_profile( + permissions, + cwd, + )), + }; + let serialized = serde_json::to_value(WireFsReadFileParams::from(params.clone())) + .expect("serialize filesystem wire request"); + assert_eq!( + serialized["sandbox"]["permissions"], + serde_json::json!({ + "type": "managed", + "file_system": { + "type": "restricted", + "entries": [ + {"path": {"type": "path", "path": "file:///C:/a%20b"}, "access": "read"}, + {"path": {"type": "path", "path": "file://host/s/a%20b"}, "access": "read"} + ] + }, + "network": "restricted" + }), + ); + assert_eq!( + serde_json::from_value::(serialized) + .expect("deserialize filesystem wire request") + .try_into_request(|wire| { + let cwd = wire.cwd().expect("explicit client policy cwd").clone(); + Ok(wire.into_context(cwd)) + }) + .expect("resolve filesystem request"), + params, + ); + } + + /// Executor ingress must use the full policy context instead of interpreting legacy helper fields. + #[test] + fn filesystem_protocol_prefers_explicit_policy_paths_over_legacy_fields() { + let cwd = PathUri::parse("file:///workspace/selected").expect("selected policy cwd"); + let workspace = PathUri::parse("file:///workspace/other").expect("selected workspace"); + let path = cwd.join("note.txt").expect("read path"); + let sandbox = FileSystemSandboxContext { + workspace_roots: vec![workspace], + ..FileSystemSandboxContext::from_permission_profile(PermissionProfile::Disabled, cwd) + }; + let params = FsReadFileParams { + path, + follow_symlinks: None, + sandbox: Some(sandbox), + }; + let mut json = serde_json::to_value(WireFsReadFileParams::from(params.clone())) + .expect("serialize wire read"); + json["sandbox"]["cwd"] = serde_json::json!("file:///legacy"); + json["sandbox"]["workspaceRoots"] = serde_json::json!(["file:///legacy"]); + let wire: WireFsReadFileParams = serde_json::from_value(json).expect("wire read"); + let request = wire + .try_into_request(|wire| { + let cwd = wire.cwd().expect("explicit policy cwd").clone(); + Ok(wire.into_context(cwd)) + }) + .expect("strict executor read"); + assert_eq!(request, params); + } + + /// Only filesystem RPCs need the additive policy field; capability discovery keeps its original wire cwd. + #[test] + fn filesystem_open_and_capability_discovery_keep_their_existing_cwd_contracts() { + let cwd = PathUri::parse("file:///D:/selected").expect("Windows policy cwd"); + let path = cwd.join("note.txt").expect("read path"); + let mut sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::Disabled, + cwd.clone(), + ); + sandbox.windows_sandbox_selection = WindowsSandboxSelection::Mxc; + let open = FsOpenParams { + handle_id: "handle".to_owned(), + path: path.clone(), + sandbox: Some(sandbox.clone()), + }; + let capability = CapabilityRootDiscoverRequest { + id: "root".to_owned(), + path, + sandbox: Some(sandbox), + }; + let open_wire = + serde_json::to_value(WireFsOpenParams::from(open.clone())).expect("serialize open"); + let capability_wire = serde_json::to_value(&capability).expect("serialize capability"); + assert_eq!( + ( + open_wire["sandbox"].get("cwd"), + open_wire["sandbox"].get("workspaceRoots"), + open_wire["sandbox"].get("policyContext"), + open_wire["sandbox"].get("windowsSandboxLevel"), + capability_wire["sandbox"].get("cwd"), + capability_wire["sandbox"].get("workspaceRoots"), + capability_wire["sandbox"].get("policyContext"), + capability_wire["sandbox"].get("windowsSandboxLevel"), + ), + ( + None, + None, + Some(&serde_json::json!({"cwd": cwd, "workspaceRoots": [cwd]})), + Some(&serde_json::json!("mxc")), + Some(&serde_json::json!(cwd)), + Some(&serde_json::json!([cwd])), + None, + Some(&serde_json::json!("mxc")), + ) + ); + assert_eq!( + serde_json::from_value::(open_wire) + .expect("deserialize wire open") + .try_into_request(|wire| { + let cwd = wire.cwd().expect("explicit client policy cwd").clone(); + Ok(wire.into_context(cwd)) + }) + .expect("resolve open"), + open + ); + assert_eq!( + serde_json::from_value::(capability_wire) + .expect("deserialize capability"), + capability + ); + } + #[test] fn filesystem_protocol_round_trips_legacy_policy_paths_as_uris() { let native_cwd = std::env::current_dir().expect("current directory"); @@ -1339,8 +1737,7 @@ mod tests { &file_system_policy, NetworkSandboxPolicy::Restricted, ); - let sandbox = - FileSystemSandboxContext::from_permission_profile_with_cwd(permissions, cwd.clone()); + let sandbox = FileSystemSandboxContext::from_permission_profile(permissions, cwd.clone()); let serialized = serde_json::to_value(&sandbox).expect("serialize sandbox"); diff --git a/codex-rs/exec-server/BUILD.bazel b/codex-rs/exec-server/BUILD.bazel index 4fba596f48cb..cf8c72ae7947 100644 --- a/codex-rs/exec-server/BUILD.bazel +++ b/codex-rs/exec-server/BUILD.bazel @@ -20,6 +20,8 @@ codex_rust_crate( ], extra_binaries = [ "//codex-rs/bwrap:bwrap", + "//codex-rs/windows-sandbox-rs:codex-command-runner", + "//codex-rs/windows-sandbox-rs:codex-windows-sandbox-setup", ], integration_compile_data_extra = [ "src/proto/codex.exec_server.relay.v1.rs", diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index bc441d9fe4f4..50c728b04a1a 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -83,6 +83,7 @@ windows-sys = { version = "0.52", features = [ anyhow = { workspace = true } codex-exec-server-test-support = { workspace = true } codex-test-binary-support = { workspace = true } +codex-utils-cargo-bin = { workspace = true } ctor = { workspace = true } http = { workspace = true } opentelemetry_sdk = { workspace = true } diff --git a/codex-rs/exec-server/src/capability_discovery.rs b/codex-rs/exec-server/src/capability_discovery.rs index c1c5f448838a..4799db98d2ba 100644 --- a/codex-rs/exec-server/src/capability_discovery.rs +++ b/codex-rs/exec-server/src/capability_discovery.rs @@ -76,6 +76,13 @@ async fn discover_root( error: None, }; + if let Some(sandbox) = sandbox + && let Err(error) = sandbox.validate_file_system_paths_for_current_host() + { + discovery.error = Some(format!("failed to inspect capability root {path}: {error}")); + return discovery; + } + #[cfg(target_os = "windows")] if sandbox.is_some_and(|context| { context.should_run_in_sandbox() && !context.windows_sandbox_is_requested() diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index a8baa33fde80..99d0255493ec 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -120,6 +120,16 @@ use crate::protocol::SignalParams; use crate::protocol::SignalResponse; use crate::protocol::TerminateParams; use crate::protocol::TerminateResponse; +use crate::protocol::WireFsCanonicalizeParams; +use crate::protocol::WireFsCopyParams; +use crate::protocol::WireFsCreateDirectoryParams; +use crate::protocol::WireFsGetMetadataParams; +use crate::protocol::WireFsOpenParams; +use crate::protocol::WireFsReadDirectoryParams; +use crate::protocol::WireFsReadFileParams; +use crate::protocol::WireFsRemoveParams; +use crate::protocol::WireFsWalkParams; +use crate::protocol::WireFsWriteFileParams; use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; @@ -900,11 +910,13 @@ impl ExecServerClient { &self, params: FsReadFileParams, ) -> Result { - self.call(FS_READ_FILE_METHOD, ¶ms).await + self.call(FS_READ_FILE_METHOD, &WireFsReadFileParams::from(params)) + .await } pub async fn fs_open(&self, params: FsOpenParams) -> Result { - self.call(FS_OPEN_METHOD, ¶ms).await + self.call(FS_OPEN_METHOD, &WireFsOpenParams::from(params)) + .await } pub async fn fs_read_block( @@ -925,50 +937,70 @@ impl ExecServerClient { &self, params: FsWriteFileParams, ) -> Result { - self.call(FS_WRITE_FILE_METHOD, ¶ms).await + self.call(FS_WRITE_FILE_METHOD, &WireFsWriteFileParams::from(params)) + .await } pub async fn fs_create_directory( &self, params: FsCreateDirectoryParams, ) -> Result { - self.call(FS_CREATE_DIRECTORY_METHOD, ¶ms).await + self.call( + FS_CREATE_DIRECTORY_METHOD, + &WireFsCreateDirectoryParams::from(params), + ) + .await } pub async fn fs_get_metadata( &self, params: FsGetMetadataParams, ) -> Result { - self.call(FS_GET_METADATA_METHOD, ¶ms).await + self.call( + FS_GET_METADATA_METHOD, + &WireFsGetMetadataParams::from(params), + ) + .await } pub async fn fs_canonicalize( &self, params: FsCanonicalizeParams, ) -> Result { - self.call(FS_CANONICALIZE_METHOD, ¶ms).await + self.call( + FS_CANONICALIZE_METHOD, + &WireFsCanonicalizeParams::from(params), + ) + .await } pub async fn fs_read_directory( &self, params: FsReadDirectoryParams, ) -> Result { - self.call(FS_READ_DIRECTORY_METHOD, ¶ms).await + self.call( + FS_READ_DIRECTORY_METHOD, + &WireFsReadDirectoryParams::from(params), + ) + .await } pub async fn fs_walk(&self, params: FsWalkParams) -> Result { - self.call(FS_WALK_METHOD, ¶ms).await + self.call(FS_WALK_METHOD, &WireFsWalkParams::from(params)) + .await } pub async fn fs_remove( &self, params: FsRemoveParams, ) -> Result { - self.call(FS_REMOVE_METHOD, ¶ms).await + self.call(FS_REMOVE_METHOD, &WireFsRemoveParams::from(params)) + .await } pub async fn fs_copy(&self, params: FsCopyParams) -> Result { - self.call(FS_COPY_METHOD, ¶ms).await + self.call(FS_COPY_METHOD, &WireFsCopyParams::from(params)) + .await } pub(crate) async fn start_process( diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 26b8825d727f..bc1e68491f32 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1838,7 +1838,7 @@ mod tests { let source = sandbox_cwd .to_abs_path() .expect_err("sandbox cwd should not be native to this host"); - let sandbox = crate::FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = crate::FileSystemSandboxContext::from_permission_profile( codex_protocol::models::PermissionProfile::workspace_write(), sandbox_cwd.clone(), ); @@ -1890,6 +1890,8 @@ mod tests { &codex_protocol::permissions::FileSystemSandboxPolicy::restricted(Vec::new()), codex_protocol::permissions::NetworkSandboxPolicy::Restricted, ), + PathUri::from_host_native_path(std::env::current_dir().expect("read current dir")) + .expect("cwd URI"), ); let err = environment diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 74a20d1a5c0c..945b43901b78 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -20,6 +20,10 @@ use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; #[cfg(not(target_os = "linux"))] use codex_utils_absolute_path::canonicalize_preserving_symlinks; +#[cfg(any(windows, test))] +use codex_utils_path_uri::LegacyAppPathString; +#[cfg(any(windows, test))] +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; #[cfg(any(windows, test))] use tokio::io::AsyncBufReadExt; @@ -34,7 +38,6 @@ use crate::fs_helper::CODEX_FS_HELPER_ARG1; use crate::fs_helper::FsHelperPayload; use crate::fs_helper::FsHelperRequest; use crate::fs_helper::FsHelperResponse; -use crate::local_file_system::current_sandbox_cwd; use crate::rpc::internal_error; use crate::rpc::invalid_request; @@ -100,12 +103,13 @@ impl FileSystemSandboxRunner { .map(native_workspace_root) .collect::, _>>()?; let workspace_roots = native_workspace_roots.as_slice(); - let native_permissions: PermissionProfile = - sandbox.permissions.clone().try_into().map_err(|err| { - invalid_request(format!("invalid sandbox permission path URI: {err}")) - })?; - let native_permissions = - native_permissions.materialize_project_roots_with_workspace_roots(workspace_roots); + sandbox + .validate_file_system_paths_for_current_host() + .map_err(|err| invalid_request(err.to_string()))?; + let native_permissions = sandbox + .permissions + .clone() + .materialize_project_roots_with_workspace_roots(workspace_roots); let mut file_system_policy = native_permissions.file_system_sandbox_policy(); tracing::Span::current().record("permission_entries", file_system_policy.entries.len()); let helper_read_roots = if sandbox.use_legacy_landlock { @@ -122,6 +126,8 @@ impl FileSystemSandboxRunner { // unrelated permission roots synchronously on the executor's runtime thread. #[cfg(not(target_os = "linux"))] normalize_file_system_policy_root_aliases(&mut file_system_policy); + #[cfg(windows)] + bind_windows_cwd_relative_deny_read_globs(&mut file_system_policy, &cwd.uri)?; let network_policy = NetworkSandboxPolicy::Restricted; let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( native_permissions.enforcement(), @@ -155,10 +161,17 @@ impl FileSystemSandboxRunner { "filesystem sandbox cannot be enforced on this executor".to_string(), )); } + // Requests use absolute paths; the helper can start at the filesystem root even if + // the policy cwd was removed. Keep its drive or share for Windows `:root` rules. + let helper_cwd = cwd + .native + .ancestors() + .last() + .ok_or_else(|| invalid_request("filesystem sandbox cwd has no root".to_string()))?; let command = SandboxCommand { program: helper.as_path().as_os_str().to_owned(), args: vec![CODEX_FS_HELPER_ARG1.to_string()], - cwd: cwd.uri.clone(), + cwd: PathUri::from_abs_path(&helper_cwd), env: self.helper_env.clone(), managed_network: None, additional_permissions: None, @@ -193,23 +206,10 @@ impl FileSystemSandboxRunner { } fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result { - if let Some(uri) = &sandbox.cwd { - return Ok(SandboxCwd { - native: native_sandbox_cwd(uri)?, - uri: uri.clone(), - }); - } - - if sandbox.has_cwd_dependent_permissions() { - return Err(invalid_request( - "file system sandbox context with dynamic permissions requires cwd".to_string(), - )); - } - - let native = AbsolutePathBuf::from_absolute_path(current_sandbox_cwd().map_err(io_error)?) - .map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))?; - let uri = PathUri::from_abs_path(&native); - Ok(SandboxCwd { uri, native }) + Ok(SandboxCwd { + native: native_sandbox_cwd(&sandbox.cwd)?, + uri: sandbox.cwd.clone(), + }) } fn native_sandbox_cwd(cwd: &PathUri) -> Result { @@ -264,6 +264,34 @@ fn add_helper_runtime_permissions( } } +#[cfg(any(windows, test))] +fn bind_windows_cwd_relative_deny_read_globs( + file_system_policy: &mut FileSystemSandboxPolicy, + cwd: &PathUri, +) -> Result<(), JSONRPCErrorError> { + // The Windows direct-spawn wrapper reevaluates the profile using the helper cwd. + // Bind cwd-relative denials before moving the helper to the filesystem root. + for entry in &mut file_system_policy.entries { + if let FileSystemPath::GlobPattern { pattern } = &mut entry.path + && entry.access == FileSystemAccessMode::Deny + && PathConvention::Windows + .home_relative_suffix(pattern) + .is_none() + && LegacyAppPathString::from_string(pattern.as_str()) + .to_path_uri(PathConvention::Windows) + .is_err() + { + cwd.validate_glob_directory(PathConvention::Windows) + .map_err(|err| invalid_request(err.to_string()))?; + *pattern = cwd + .join(pattern.as_str()) + .map_err(|err| invalid_request(err.to_string()))? + .inferred_native_path_string(); + } + } + Ok(()) +} + #[cfg(not(target_os = "linux"))] fn normalize_file_system_policy_root_aliases(file_system_policy: &mut FileSystemSandboxPolicy) { for entry in &mut file_system_policy.entries { @@ -542,6 +570,7 @@ mod tests { use super::FileSystemSandboxRunner; use super::SandboxCwd; use super::add_helper_runtime_permissions; + use super::bind_windows_cwd_relative_deny_read_globs; use super::helper_env; use super::helper_env_from_vars; use super::helper_env_key_is_allowed; @@ -740,6 +769,147 @@ mod tests { assert_eq!(request.env.get(&path_key), Some(&path)); } + /// Removing the selected directory must not change permission anchoring or prevent launch. + #[test] + fn sandbox_exec_request_uses_filesystem_root_and_preserves_policy_cwd() { + let codex_self_exe = std::env::current_exe().expect("current exe"); + let runtime_paths = + ExecServerRuntimePaths::new(codex_self_exe.clone(), Some(codex_self_exe)) + .expect("runtime paths"); + let runner = FileSystemSandboxRunner::new(runtime_paths); + let selected = tempfile::tempdir().expect("selected directory"); + let selected_cwd = AbsolutePathBuf::from_absolute_path(selected.path()).expect("cwd"); + let root = selected_cwd.ancestors().last().expect("filesystem root"); + let policy = restricted_policy(vec![special_entry( + FileSystemSpecialPath::Root, + FileSystemAccessMode::Read, + )]); + let sandbox_context = + sandbox_context_with_cwd(&policy, PathUri::from_abs_path(&selected_cwd)); + #[cfg(windows)] + let sandbox_context = crate::FileSystemSandboxContext { + windows_sandbox_selection: codex_file_system::WindowsSandboxSelection::RestrictedToken, + ..sandbox_context + }; + selected.close().expect("remove selected directory"); + + let request = runner + .sandbox_command(&sandbox_context) + .expect("sandbox command"); + + assert_eq!( + (request.cwd, request.sandbox_policy_cwd), + ( + PathUri::from_abs_path(&root), + PathUri::from_abs_path(&selected_cwd) + ) + ); + } + + /// Tilde patterns stay home-relative when Windows sandbox commands start at another cwd. + #[test] + fn windows_cwd_relative_denial_binding_keeps_tilde_patterns_home_relative() { + let cwd = PathUri::parse("file:///C:/policy/checkout").expect("Windows policy cwd"); + let mut policy = restricted_policy( + [ + "~", + "~/", + r"~\", + "~/private/*.env", + r"~\private\*.env", + "hidden/*.env", + "~someone/private", + "D:/absolute/*.env", + ] + .into_iter() + .map(|pattern| { + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: pattern.to_string(), + }, + FileSystemAccessMode::Deny, + ) + }) + .collect(), + ); + + bind_windows_cwd_relative_deny_read_globs(&mut policy, &cwd).expect("bind Windows policy"); + + let patterns = policy + .entries + .into_iter() + .filter_map(|entry| match entry.path { + FileSystemPath::GlobPattern { pattern } => Some(pattern), + FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => None, + }) + .collect::>(); + assert_eq!( + patterns, + [ + "~", + "~/", + r"~\", + "~/private/*.env", + r"~\private\*.env", + r"C:\policy\checkout\hidden\*.env", + r"C:\policy\checkout\~someone\private", + "D:/absolute/*.env", + ] + ); + } + + /// Windows wrapper policy checks must keep cwd-relative denials anchored to the selection. + #[cfg(windows)] + #[test] + fn sandbox_exec_request_binds_windows_relative_globs_to_policy_cwd() { + let codex_self_exe = std::env::current_exe().expect("current exe"); + let runtime_paths = + ExecServerRuntimePaths::new(codex_self_exe, /*codex_linux_sandbox_exe*/ None) + .expect("runtime paths"); + let runner = FileSystemSandboxRunner::new(runtime_paths); + let selected = tempfile::tempdir().expect("selected directory"); + let selected_cwd = AbsolutePathBuf::from_absolute_path(selected.path()).expect("cwd"); + let cwd_uri = PathUri::from_abs_path(&selected_cwd); + let pattern = "hidden/*.env"; + let absolute_pattern = cwd_uri + .join(pattern) + .expect("absolute glob") + .inferred_native_path_string(); + let policy = restricted_policy(vec![ + special_entry(FileSystemSpecialPath::Root, FileSystemAccessMode::Read), + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: pattern.to_string(), + }, + FileSystemAccessMode::Deny, + ), + ]); + let sandbox_context = crate::FileSystemSandboxContext { + windows_sandbox_selection: codex_file_system::WindowsSandboxSelection::Elevated, + ..sandbox_context_with_cwd(&policy, cwd_uri) + }; + selected.close().expect("remove selected directory"); + + let request = runner + .sandbox_command(&sandbox_context) + .expect("sandbox command"); + + assert_eq!( + request + .permission_profile + .file_system_sandbox_policy() + .entries + .into_iter() + .find(|entry| { matches!(entry.path, FileSystemPath::GlobPattern { .. }) }), + Some(FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: absolute_pattern + }, + FileSystemAccessMode::Deny, + )) + ); + } + #[test] fn sandbox_cwd_uses_context_cwd() { let native_cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) @@ -780,27 +950,6 @@ mod tests { ); } - #[test] - fn sandbox_cwd_rejects_cwd_dependent_profile_without_context_cwd() { - let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Write, - missing_path_behavior: None, - }]); - let sandbox_context = codex_file_system::FileSystemSandboxContext::from_permission_profile( - PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), - ); - - let err = sandbox_cwd(&sandbox_context).expect_err("missing cwd should be rejected"); - - assert_eq!( - err.message, - "file system sandbox context with dynamic permissions requires cwd" - ); - } - #[test] fn helper_permissions_include_only_the_helper_executable() { let codex_self_exe = std::env::current_exe().expect("current exe"); @@ -875,7 +1024,7 @@ mod tests { policy: &FileSystemSandboxPolicy, cwd: PathUri, ) -> crate::FileSystemSandboxContext { - codex_file_system::FileSystemSandboxContext::from_permission_profile_with_cwd( + codex_file_system::FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(policy, NetworkSandboxPolicy::Restricted), cwd, ) diff --git a/codex-rs/exec-server/src/local_file_system.rs b/codex-rs/exec-server/src/local_file_system.rs index 63048da72ad3..f4b29363f2ad 100644 --- a/codex-rs/exec-server/src/local_file_system.rs +++ b/codex-rs/exec-server/src/local_file_system.rs @@ -99,6 +99,9 @@ impl LocalFileSystem { &'a dyn ExecutorFileSystem, Option<&'a FileSystemSandboxContext>, )> { + if let Some(sandbox) = sandbox { + sandbox.validate_file_system_paths_for_current_host()?; + } if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) { Ok((self.sandboxed()?, sandbox)) } else { @@ -113,6 +116,9 @@ impl LocalFileSystem { path: &PathUri, sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult { + if let Some(sandbox) = sandbox { + sandbox.validate_file_system_paths_for_current_host()?; + } if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) { return self.sandboxed()?.open_file_for_read(path, sandbox).await; } @@ -1225,12 +1231,6 @@ pub(crate) fn resolve_existing_path(path: &Path) -> io::Result { Ok(resolved) } -pub(crate) fn current_sandbox_cwd() -> io::Result { - let cwd = std::env::current_dir() - .map_err(|err| io::Error::other(format!("failed to read current dir: {err}")))?; - resolve_existing_path(cwd.as_path()) -} - fn copy_symlink(source: &Path, target: &Path) -> io::Result<()> { let link_target = std::fs::read_link(source)?; #[cfg(unix)] @@ -1349,6 +1349,7 @@ mod walk_tests { &FileSystemSandboxPolicy::restricted(Vec::new()), NetworkSandboxPolicy::Restricted, ), + root.clone(), ); let options = WalkOptions { max_depth: 1, diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 035bfa8831b1..5bc2b5d9fc57 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -145,19 +145,18 @@ pub(crate) async fn prepare_exec_request_with_telemetry( windows_sandbox: None, }); }; + let runtime_paths = runtime_paths + .ok_or_else(|| invalid_params("sandbox runtime paths are not configured".to_string()))?; + sandbox_context + .validate_file_system_paths_for_current_host() + .map_err(|err| invalid_params(err.to_string()))?; let windows_sandbox_proxy_settings_mode = sandbox_context .windows_sandbox_proxy_settings_mode .unwrap_or_default(); - let runtime_paths = runtime_paths - .ok_or_else(|| invalid_params("sandbox runtime paths are not configured".to_string()))?; // TODO(jif): Transport permissions before orchestrator-local paths are materialized, // then resolve executor-local helper and workspace paths here. - let permissions: PermissionProfile = sandbox_context - .permissions - .clone() - .try_into() - .map_err(|err| invalid_params(format!("invalid sandbox permission path URI: {err}")))?; - let sandbox_policy_cwd = sandbox_context.cwd.as_ref().unwrap_or(¶ms.cwd); + let permissions = sandbox_context.permissions.clone(); + let sandbox_policy_cwd = &sandbox_context.cwd; let native_sandbox_policy_cwd = native_path(sandbox_policy_cwd, "sandbox cwd")?; let native_workspace_roots = sandbox_context .workspace_roots diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 7e66b2e419c5..7f415f4dde74 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -27,6 +27,8 @@ use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; +#[cfg(unix)] +use tempfile::tempdir; #[cfg(windows)] use test_case::test_case; use tokio::io::AsyncReadExt; @@ -65,15 +67,13 @@ async fn prepare_exec_request( #[cfg(unix)] #[tokio::test] async fn sandbox_request_wraps_native_argv_on_executor() { - let cwd: AbsolutePathBuf = std::env::current_dir() - .expect("current directory") - .try_into() - .expect("absolute cwd"); + let command_directory = tempdir().expect("command directory"); + let cwd = AbsolutePathBuf::from_absolute_path(command_directory.path()).expect("absolute cwd"); let cwd_uri = PathUri::from_abs_path(&cwd); let self_exe = std::env::current_exe().expect("current executable"); let runtime_paths = ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::workspace_write(), cwd_uri.clone(), ); @@ -129,6 +129,37 @@ async fn sandbox_request_wraps_native_argv_on_executor() { PermissionProfile::workspace_write() .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)) ); + + let policy_directory = tempdir().expect("policy directory"); + let explicit_cwd = + AbsolutePathBuf::from_absolute_path(policy_directory.path()).expect("policy cwd"); + let mut params = params.clone(); + for policy_cwd in [&cwd, &explicit_cwd] { + params.sandbox.as_mut().expect("sandbox").cwd = PathUri::from_abs_path(policy_cwd); + let prepared = prepare_exec_request( + ¶ms, + HashMap::new(), + Some(&runtime_paths), + /*network_policy_decider*/ None, + /*network_policy_audit_observer*/ None, + ) + .await + .expect("prepare sandboxed request"); + let actual_cwds = prepared + .command + .windows(4) + .find(|args| args[0] == "--sandbox-policy-cwd") + .expect("sandbox wrapper cwd arguments"); + assert_eq!( + actual_cwds, + [ + "--sandbox-policy-cwd", + policy_cwd.to_string_lossy().as_ref(), + "--command-cwd", + cwd.to_string_lossy().as_ref(), + ] + ); + } } #[cfg(target_os = "macos")] assert_eq!( @@ -162,7 +193,7 @@ async fn sandbox_request_routes_custom_arg0_to_inner_helper() { let self_exe = std::env::current_exe().expect("current executable"); let runtime_paths = ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::workspace_write(), cwd_uri.clone(), ); @@ -224,7 +255,7 @@ fn managed_network_sandbox_request() -> (ExecParams, ExecServerRuntimePaths) { let self_exe = std::env::current_exe().expect("current executable"); let runtime_paths = ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::workspace_write(), cwd_uri.clone(), ); @@ -617,10 +648,8 @@ async fn managed_network_honors_windows_sandbox_level(windows_sandbox_level: Win let self_exe = std::env::current_exe().expect("current executable"); let runtime_paths = ExecServerRuntimePaths::new(self_exe, None).expect("runtime paths"); let permissions = PermissionProfile::read_only(); - let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( - permissions.clone(), - cwd_uri.clone(), - ); + let mut sandbox = + FileSystemSandboxContext::from_permission_profile(permissions.clone(), cwd_uri.clone()); sandbox.windows_sandbox_selection = windows_sandbox_level.into(); sandbox.windows_sandbox_proxy_settings_mode = Some(codex_sandboxing::WindowsSandboxProxySettingsMode::Preserve); diff --git a/codex-rs/exec-server/src/remote_file_stream.rs b/codex-rs/exec-server/src/remote_file_stream.rs index 107dea51d7d5..f71e5fd1eedf 100644 --- a/codex-rs/exec-server/src/remote_file_stream.rs +++ b/codex-rs/exec-server/src/remote_file_stream.rs @@ -24,7 +24,7 @@ struct FileReadRegistration { pub(super) async fn open( client: ExecServerClient, path: PathUri, - sandbox: Option, + sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult { let registration = FileReadRegistration { client, @@ -37,7 +37,7 @@ pub(super) async fn open( .fs_open(FsOpenParams { handle_id: registration.handle_id.clone(), path, - sandbox, + sandbox: sandbox.cloned(), }) .await .map_err(map_remote_error)?; diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index d574cece8c7e..33d3b0be690c 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -106,7 +106,7 @@ impl RemoteFileSystem { ) -> FileSystemResult { trace!("remote fs read_file_stream"); let client = self.client.get().await.map_err(map_remote_error)?; - file_stream::open(client, path.clone(), remote_sandbox_context(sandbox)).await + file_stream::open(client, path.clone(), sandbox).await } async fn write_file( @@ -410,9 +410,7 @@ impl ExecutorFileSystem for RemoteFileSystem { fn remote_sandbox_context( sandbox: Option<&FileSystemSandboxContext>, ) -> Option { - sandbox - .cloned() - .map(FileSystemSandboxContext::drop_cwd_if_unused) + sandbox.cloned() } fn map_remote_error(error: ExecServerError) -> io::Error { @@ -451,7 +449,7 @@ mod tests { use super::*; #[test] - fn remote_sandbox_context_drops_unused_cwd() { + fn remote_sandbox_context_preserves_cwd_for_absolute_permissions() { let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: absolute_test_path("remote-root").into(), @@ -461,15 +459,14 @@ mod tests { }]); let permissions = PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted); - let sandbox_context = FileSystemSandboxContext::from_permission_profile_with_cwd( - permissions, - path_uri("host-checkout"), - ); + let cwd = path_uri("executor-checkout"); + let sandbox_context = + FileSystemSandboxContext::from_permission_profile(permissions, cwd.clone()); let remote_context = remote_sandbox_context(Some(&sandbox_context)).expect("remote sandbox context"); - assert_eq!(remote_context.cwd, None); + assert_eq!(remote_context.cwd, cwd); } #[test] @@ -485,12 +482,12 @@ mod tests { PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted); let cwd = path_uri("host-checkout"); let sandbox_context = - FileSystemSandboxContext::from_permission_profile_with_cwd(permissions, cwd.clone()); + FileSystemSandboxContext::from_permission_profile(permissions, cwd.clone()); let remote_context = remote_sandbox_context(Some(&sandbox_context)).expect("remote sandbox context"); - assert_eq!(remote_context.cwd, Some(cwd)); + assert_eq!(remote_context.cwd, cwd); } #[test] diff --git a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs index 4956f4bfdb72..813574d1b1de 100644 --- a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs +++ b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs @@ -42,7 +42,10 @@ use crate::protocol::FsReadFileResponse; use crate::protocol::INITIALIZE_METHOD; use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeResponse; +use crate::protocol::WireFsGetMetadataParams; +use crate::protocol::WireFsReadFileParams; +/// Absolute policies preserve foreign selection paths without adding legacy policy directories. #[tokio::test] async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conversion() { let (websocket_url, captured_params, server) = @@ -59,17 +62,20 @@ async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conve PathUri::parse("file://server/share/src/main.rs").expect("valid UNC URI"), ]; let sandbox_cwd = non_native_cwd(); - let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Write, - missing_path_behavior: None, - }]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let workspace_root = paths[0].parent().expect("workspace root URI"); + let policy = FileSystemSandboxPolicy::restricted( + paths + .iter() + .map(|path| { + FileSystemSandboxEntry::new(path.clone().into(), FileSystemAccessMode::Read) + }) + .collect(), + ); + let mut sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), sandbox_cwd, ); + sandbox.workspace_roots = vec![workspace_root]; for path in &paths { assert_eq!( @@ -81,6 +87,7 @@ async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conve ); } + let captured_params = captured_params.await.expect("captured params"); let expected_params = paths .into_iter() .map(|path| FsReadFileParams { @@ -90,12 +97,279 @@ async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conve }) .collect::>(); assert_eq!( - captured_params.await.expect("captured params"), + captured_params + .iter() + .map(|(params, _)| params.clone()) + .collect::>(), + expected_params + ); + let policy_context = serde_json::json!({ + "cwd": sandbox.cwd, + "workspaceRoots": sandbox.workspace_roots, + }); + for (_, raw_sandbox) in captured_params { + assert_eq!( + ( + raw_sandbox.get("cwd"), + raw_sandbox.get("workspaceRoots"), + raw_sandbox.get("policyContext"), + ), + (None, None, Some(&policy_context)), + ); + } + server.await.expect("recording server should succeed"); +} + +/// Only dynamic policies retain legacy selection fields; all policies preserve the new context. +#[tokio::test] +async fn remote_file_system_preserves_only_cwd_dependent_policy_directories() { + let cases = [ + ( + "file:///workspace/checkout", + Some("/elsewhere/*.secret"), + None, + Some("selected-root"), + ), + ( + "file:///D:/checkout", + Some(r"D:\elsewhere\*.secret"), + None, + Some("selected-root"), + ), + ( + "file://server/share/checkout", + Some(r"\\server\share\elsewhere\*.secret"), + None, + Some("selected-root"), + ), + ( + "file:///workspace/checkout", + Some("*.secret"), + Some("file:///workspace/checkout"), + Some("selected-root"), + ), + ( + "file:///D:/checkout", + Some(r"private\*.secret"), + Some("file:///D:/checkout"), + Some("selected-root"), + ), + ( + "file:///workspace/checkout", + None, + Some("file:///workspace/checkout"), + Some("selected-root"), + ), + ( + "file:///D:/checkout", + None, + Some("file:///D:/checkout"), + None, + ), + ]; + let (websocket_url, captured_params, server) = record_read_file_params(cases.len()).await; + let file_system = RemoteFileSystem::new(LazyRemoteExecServerClient::new( + ExecServerTransportParams::websocket_url( + websocket_url, + DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + ), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + )); + let mut expected_params = Vec::new(); + let mut expected_contexts = Vec::new(); + for (cwd, pattern, legacy_cwd, selected_root) in cases { + let cwd = PathUri::parse(cwd).expect("policy cwd"); + let path = cwd.join("public.txt").expect("operation path"); + let permission_path = match pattern { + Some(pattern) => FileSystemPath::GlobPattern { + pattern: pattern.to_string(), + }, + None => FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + }; + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( + permission_path, + FileSystemAccessMode::Deny, + )]); + let mut sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + sandbox.workspace_roots = selected_root + .map(|root| cwd.join(root).expect("selected workspace root")) + .into_iter() + .collect(); + file_system + .read_file(&path, Default::default(), Some(&sandbox)) + .await + .expect("remote read"); + let legacy_cwd = legacy_cwd + .map(|cwd| serde_json::json!(PathUri::parse(cwd).expect("legacy policy cwd"))); + let legacy_roots = if legacy_cwd.is_some() && !sandbox.workspace_roots.is_empty() { + Some(serde_json::json!(sandbox.workspace_roots)) + } else { + None + }; + expected_contexts.push(( + legacy_cwd, + legacy_roots, + Some(serde_json::json!({ + "cwd": cwd, + "workspaceRoots": sandbox.workspace_roots, + })), + )); + expected_params.push(FsReadFileParams { + path, + follow_symlinks: None, + sandbox: Some(sandbox), + }); + } + let captured_params = captured_params.await.expect("captured params"); + assert_eq!( + captured_params + .iter() + .map(|(params, _)| params.clone()) + .collect::>(), expected_params ); + assert_eq!( + captured_params + .iter() + .map(|(_, sandbox)| { + ( + sandbox.get("cwd").cloned(), + sandbox.get("workspaceRoots").cloned(), + sandbox.get("policyContext").cloned(), + ) + }) + .collect::>(), + expected_contexts, + ); server.await.expect("recording server should succeed"); } +/// Older executors can launch with their own cwd while newer executors retain the removed cwd. +#[tokio::test] +async fn remote_file_system_can_read_after_checkout_removal_with_legacy_helper_launch() { + let temp = tempfile::TempDir::new().expect("test directory"); + let checkout = temp.path().join("checkout"); + std::fs::create_dir(&checkout).expect("checkout should exist"); + let path = temp.path().join("allowed.txt"); + std::fs::write(&path, "allowed").expect("fixture should be writable"); + let path = PathUri::from_host_native_path(path).expect("absolute file path"); + let cwd = PathUri::from_host_native_path(&checkout).expect("absolute checkout"); + std::fs::remove_dir(checkout).expect("checkout should be removed"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( + path.clone().into(), + FileSystemAccessMode::Read, + )]); + let sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!("ws://{}", listener.local_addr().expect("listener address")); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("listener should accept"); + let mut websocket = accept_async(stream) + .await + .expect("websocket handshake should succeed"); + complete_websocket_initialize(&mut websocket).await; + let JSONRPCMessage::Request(request) = read_jsonrpc_websocket(&mut websocket).await else { + panic!("expected fs/readFile request"); + }; + assert_eq!(request.method, FS_READ_FILE_METHOD); + let raw_params = request.params.expect("read params"); + let raw_sandbox = raw_params + .get("sandbox") + .expect("raw sandbox should be sent"); + let policy_context = serde_json::json!({ + "cwd": cwd, + "workspaceRoots": [cwd], + }); + assert_eq!( + ( + raw_sandbox.get("cwd"), + raw_sandbox.get("workspaceRoots"), + raw_sandbox.get("policyContext"), + ), + (None, None, Some(&policy_context)), + ); + let legacy_cwd = raw_sandbox + .get("cwd") + .cloned() + .map(serde_json::from_value::) + .transpose() + .expect("legacy sandbox cwd"); + let params = serde_json::from_value::(raw_params) + .expect("wire read params") + .try_into_request(|wire| { + let cwd = wire.cwd().expect("explicit client policy cwd").clone(); + Ok(wire.into_context(cwd)) + }) + .expect("typed read params"); + let selected_sandbox = params.sandbox.expect("sandbox should be sent"); + assert_eq!( + (selected_sandbox.cwd, selected_sandbox.workspace_roots), + (cwd.clone(), vec![cwd]), + ); + // An old executor falls back to its own cwd when it spawns the helper for this policy. + let helper_cwd = match legacy_cwd { + Some(cwd) => cwd.to_abs_path().expect("native legacy cwd").to_path_buf(), + None => std::env::current_dir().expect("legacy executor cwd"), + }; + #[cfg(unix)] + let mut command = tokio::process::Command::new("cat"); + #[cfg(windows)] + let mut command = { + let mut command = tokio::process::Command::new("cmd.exe"); + command.args(["/C", "type"]); + command + }; + let output = command + .current_dir(helper_cwd) + .arg(params.path.to_abs_path().expect("native file").as_path()) + .output() + .await + .expect("legacy helper should launch"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + write_jsonrpc_websocket( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::to_value(FsReadFileResponse { + data_base64: STANDARD.encode(output.stdout), + }) + .expect("read response"), + }), + ) + .await; + }); + let file_system = RemoteFileSystem::new(LazyRemoteExecServerClient::new( + ExecServerTransportParams::websocket_url( + websocket_url, + DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + ), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + )); + assert_eq!( + file_system + .read_file(&path, Default::default(), Some(&sandbox)) + .await + .expect("remote read"), + b"allowed" + ); + server.await.expect("legacy server should succeed"); +} + #[tokio::test] async fn concurrent_remote_metadata_requests_share_only_in_flight_results() { let (abandoned_request_tx, abandoned_request_rx) = oneshot::channel(); @@ -390,7 +664,7 @@ async fn remote_metadata_requests_do_not_cross_path_or_sandbox_boundaries() { )); let first_path = PathUri::parse("file:///workspace/project/AGENTS.md").expect("valid path URI"); let second_path = PathUri::parse("file:///workspace/project/SKILL.md").expect("valid path URI"); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -447,7 +721,7 @@ async fn record_read_file_params( expected_requests: usize, ) -> ( String, - oneshot::Receiver>, + oneshot::Receiver>, tokio::task::JoinHandle<()>, ) { let listener = TcpListener::bind("127.0.0.1:0") @@ -470,10 +744,19 @@ async fn record_read_file_params( } other => panic!("expected fs/readFile request, got {other:?}"), }; - let params: FsReadFileParams = - serde_json::from_value(request.params.expect("fs/readFile params should exist")) - .expect("fs/readFile params should deserialize"); - captured_params.push(params); + let raw_params = request.params.expect("fs/readFile params should exist"); + let raw_sandbox = raw_params + .get("sandbox") + .expect("fs/readFile sandbox should exist") + .clone(); + let params = serde_json::from_value::(raw_params) + .expect("fs/readFile wire params should deserialize") + .try_into_request(|wire| { + let cwd = wire.cwd().expect("explicit client policy cwd").clone(); + Ok(wire.into_context(cwd)) + }) + .expect("fs/readFile params should convert"); + captured_params.push((params, raw_sandbox)); write_jsonrpc_websocket( &mut websocket, JSONRPCMessage::Response(JSONRPCResponse { @@ -554,10 +837,15 @@ async fn record_metadata_params( )); } else { assert_eq!(request.method, FS_GET_METADATA_METHOD); - let params: FsGetMetadataParams = serde_json::from_value( + let params = serde_json::from_value::( request.params.expect("fs/getMetadata params should exist"), ) - .expect("fs/getMetadata params should deserialize"); + .expect("fs/getMetadata wire params should deserialize") + .try_into_request(|wire| { + let cwd = wire.cwd().expect("explicit client policy cwd").clone(); + Ok(wire.into_context(cwd)) + }) + .expect("fs/getMetadata params should convert"); captured_params.push(params); } let response = match response { diff --git a/codex-rs/exec-server/src/sandboxed_file_system_path_uri_tests.rs b/codex-rs/exec-server/src/sandboxed_file_system_path_uri_tests.rs index 031d89080f21..f39f01e0e49c 100644 --- a/codex-rs/exec-server/src/sandboxed_file_system_path_uri_tests.rs +++ b/codex-rs/exec-server/src/sandboxed_file_system_path_uri_tests.rs @@ -1,5 +1,9 @@ use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -14,12 +18,14 @@ async fn sandboxed_file_system_rejects_non_native_uri_as_invalid_input() { /*codex_linux_sandbox_exe*/ None, ) .expect("runtime paths"); - let file_system = SandboxedFileSystem::new(runtime_paths); + let file_system = SandboxedFileSystem::new(runtime_paths.clone()); + let cwd = PathUri::from_host_native_path(std::env::temp_dir()).expect("native temporary cwd"); let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(Vec::new()), NetworkSandboxPolicy::Restricted, ), + cwd.clone(), ); let error = file_system @@ -28,6 +34,34 @@ async fn sandboxed_file_system_rejects_non_native_uri_as_invalid_input() { .expect_err("non-native URI should be rejected"); assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + + // A foreign permission path must not select the unsandboxed filesystem, even with a root grant. + let file_system = crate::LocalFileSystem::with_runtime_paths(runtime_paths); + let foreign = non_native_uri(); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Write, + ), + FileSystemSandboxEntry::new(foreign.clone().into(), FileSystemAccessMode::Write), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd, + ); + let readable = tempfile::NamedTempFile::new().expect("readable file"); + let error = file_system + .read_file( + &PathUri::from_host_native_path(readable.path()).expect("readable file URI"), + Default::default(), + Some(&sandbox), + ) + .await + .expect_err("foreign permission path must not allow unsandboxed access"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(error.to_string().contains(&foreign.to_string())); } fn non_native_uri() -> PathUri { diff --git a/codex-rs/exec-server/src/server/file_system_handler.rs b/codex-rs/exec-server/src/server/file_system_handler.rs index 8acc2a39c644..cdddddf68949 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -79,7 +79,10 @@ impl FileSystemHandler { .first() .and_then(|root| root.sandbox.as_ref()) .filter(|sandbox| { - sandbox.should_run_in_sandbox() + sandbox + .validate_file_system_paths_for_current_host() + .is_ok() + && sandbox.should_run_in_sandbox() && (!cfg!(target_os = "windows") || sandbox.windows_sandbox_is_requested()) && params .roots diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index 4cd21ad85e55..9eeba5bfa1fe 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -1,8 +1,11 @@ +use codex_file_system::FileSystemSandboxContext; +use codex_file_system::WireFileSystemSandboxContext; +use codex_utils_path_uri::PathUri; use opentelemetry::trace::TraceContextExt; use std::sync::Arc; +use crate::local_file_system::resolve_existing_path; use crate::protocol::CAPABILITY_ROOTS_DISCOVER_METHOD; -use crate::protocol::CapabilityRootsDiscoverParams; use crate::protocol::ENVIRONMENT_CONFIG_READ_METHOD; use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::ENVIRONMENT_STATUS_METHOD; @@ -12,7 +15,6 @@ use crate::protocol::EXEC_SIGNAL_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; use crate::protocol::EnvironmentConfigReadParams; -use crate::protocol::ExecParams; use crate::protocol::FS_CANONICALIZE_METHOD; use crate::protocol::FS_CLOSE_METHOD; use crate::protocol::FS_COPY_METHOD; @@ -25,28 +27,33 @@ use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FS_REMOVE_METHOD; use crate::protocol::FS_WALK_METHOD; use crate::protocol::FS_WRITE_FILE_METHOD; -use crate::protocol::FsCanonicalizeParams; use crate::protocol::FsCloseParams; -use crate::protocol::FsCopyParams; -use crate::protocol::FsCreateDirectoryParams; -use crate::protocol::FsGetMetadataParams; -use crate::protocol::FsOpenParams; use crate::protocol::FsReadBlockParams; -use crate::protocol::FsReadDirectoryParams; -use crate::protocol::FsReadFileParams; -use crate::protocol::FsRemoveParams; -use crate::protocol::FsWalkParams; -use crate::protocol::FsWriteFileParams; use crate::protocol::HTTP_REQUEST_METHOD; use crate::protocol::HttpRequestParams; use crate::protocol::INITIALIZE_METHOD; use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeParams; +use crate::protocol::JSONRPCErrorError; use crate::protocol::ReadParams; use crate::protocol::SignalParams; use crate::protocol::TerminateParams; +use crate::protocol::WireCapabilityRootsDiscoverParams; +use crate::protocol::WireExecParams; +use crate::protocol::WireFsCanonicalizeParams; +use crate::protocol::WireFsCopyParams; +use crate::protocol::WireFsCreateDirectoryParams; +use crate::protocol::WireFsGetMetadataParams; +use crate::protocol::WireFsOpenParams; +use crate::protocol::WireFsReadDirectoryParams; +use crate::protocol::WireFsReadFileParams; +use crate::protocol::WireFsRemoveParams; +use crate::protocol::WireFsWalkParams; +use crate::protocol::WireFsWriteFileParams; use crate::protocol::WriteParams; use crate::rpc::RpcRouter; +use crate::rpc::internal_error; +use crate::rpc::invalid_params; use crate::server::ExecServerHandler; pub(crate) fn build_router() -> RpcRouter { @@ -71,12 +78,12 @@ pub(crate) fn build_router() -> RpcRouter { ); router.request_with_trace( EXEC_METHOD, - |handler: Arc, params: ExecParams, trace| async move { + |handler: Arc, params: WireExecParams, trace| async move { let launch_context = trace .as_ref() .and_then(codex_otel::context_from_w3c_trace_context) .map(|context| context.span().span_context().clone()); - handler.exec(params, launch_context).await + handler.exec(params.into(), launch_context).await }, ); router.request( @@ -95,7 +102,8 @@ pub(crate) fn build_router() -> RpcRouter { ); router.request( CAPABILITY_ROOTS_DISCOVER_METHOD, - |handler: Arc, params: CapabilityRootsDiscoverParams| async move { + |handler: Arc, params: WireCapabilityRootsDiscoverParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.discover_capability_roots(params).await }, ); @@ -125,13 +133,15 @@ pub(crate) fn build_router() -> RpcRouter { ); router.request( FS_READ_FILE_METHOD, - |handler: Arc, params: FsReadFileParams| async move { + |handler: Arc, params: WireFsReadFileParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_read_file(params).await }, ); router.request( FS_OPEN_METHOD, - |handler: Arc, params: FsOpenParams| async move { + |handler: Arc, params: WireFsOpenParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_open(params).await }, ); @@ -149,51 +159,80 @@ pub(crate) fn build_router() -> RpcRouter { ); router.request( FS_WRITE_FILE_METHOD, - |handler: Arc, params: FsWriteFileParams| async move { + |handler: Arc, params: WireFsWriteFileParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_write_file(params).await }, ); router.request( FS_CREATE_DIRECTORY_METHOD, - |handler: Arc, params: FsCreateDirectoryParams| async move { + |handler: Arc, params: WireFsCreateDirectoryParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_create_directory(params).await }, ); router.request( FS_GET_METADATA_METHOD, - |handler: Arc, params: FsGetMetadataParams| async move { + |handler: Arc, params: WireFsGetMetadataParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_get_metadata(params).await }, ); router.request( FS_CANONICALIZE_METHOD, - |handler: Arc, params: FsCanonicalizeParams| async move { + |handler: Arc, params: WireFsCanonicalizeParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_canonicalize(params).await }, ); router.request( FS_READ_DIRECTORY_METHOD, - |handler: Arc, params: FsReadDirectoryParams| async move { + |handler: Arc, params: WireFsReadDirectoryParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_read_directory(params).await }, ); router.request( FS_WALK_METHOD, - |handler: Arc, params: FsWalkParams| async move { + |handler: Arc, params: WireFsWalkParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_walk(params).await }, ); router.request( FS_REMOVE_METHOD, - |handler: Arc, params: FsRemoveParams| async move { + |handler: Arc, params: WireFsRemoveParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_remove(params).await }, ); router.request( FS_COPY_METHOD, - |handler: Arc, params: FsCopyParams| async move { + |handler: Arc, params: WireFsCopyParams| async move { + let params = params.try_into_request(resolve_filesystem_sandbox)?; handler.fs_copy(params).await }, ); router } + +fn resolve_filesystem_sandbox( + sandbox: WireFileSystemSandboxContext, +) -> Result { + let cwd = if let Some(cwd) = sandbox.cwd() { + cwd.clone() + } else { + if sandbox.requires_cwd() { + return Err(invalid_params( + "file system sandbox context with dynamic permissions requires cwd".to_string(), + )); + } + // Legacy filesystem clients omitted cwd only for these policies. Preserve the executor's + // own default (and Windows drive/share), rather than using the requested path. + std::env::current_dir() + .and_then(|cwd| resolve_existing_path(&cwd)) + .and_then(PathUri::from_host_native_path) + .map_err(|err| internal_error(err.to_string()))? + }; + Ok(sandbox.into_context(cwd)) +} diff --git a/codex-rs/exec-server/tests/capability_discovery.rs b/codex-rs/exec-server/tests/capability_discovery.rs index 0be51f9fbb28..e811bfbbe50c 100644 --- a/codex-rs/exec-server/tests/capability_discovery.rs +++ b/codex-rs/exec-server/tests/capability_discovery.rs @@ -5,6 +5,8 @@ mod fake_bwrap; #[cfg(target_os = "linux")] use anyhow::Context as _; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; use codex_exec_server::CAPABILITY_ROOTS_DISCOVER_METHOD; use codex_exec_server::CapabilityRootDiscovery; use codex_exec_server::CapabilityRootsDiscoverParams; @@ -14,12 +16,26 @@ use codex_exec_server::InitializeParams; use codex_exec_server::InitializeResponse; use codex_exec_server::WindowsSandboxSelection; use codex_exec_server_protocol::CapabilityRootDiscoverRequest; +use codex_exec_server_protocol::EXEC_METHOD; +#[cfg(unix)] +use codex_exec_server_protocol::EXEC_READ_METHOD; +use codex_exec_server_protocol::FS_OPEN_METHOD; +#[cfg(unix)] +use codex_exec_server_protocol::FS_READ_BLOCK_METHOD; +use codex_exec_server_protocol::FS_READ_FILE_METHOD; +#[cfg(unix)] +use codex_exec_server_protocol::FsReadBlockResponse; +use codex_exec_server_protocol::FsReadFileResponse; use codex_exec_server_protocol::JSONRPCMessage; use codex_exec_server_protocol::JSONRPCResponse; +#[cfg(unix)] +use codex_exec_server_protocol::ReadResponse; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -183,6 +199,715 @@ async fn discovers_cursor_plugin_without_reading_default_mcp_for_inline_servers( Ok(()) } +#[tokio::test] +async fn executor_legacy_missing_or_null_sandbox_cwd_keeps_absolute_requests_working() +-> anyhow::Result<()> { + let workspace = tempfile::tempdir()?; + std::fs::write(workspace.path().join("note.txt"), b"contents")?; + write_file( + &workspace.path().join("skills/demo/SKILL.md"), + "---\nname: demo\ndescription: Demo.\n---\n", + )?; + let cwd = PathUri::from_host_native_path(workspace.path())?; + let path = cwd.join("note.txt")?; + let program = std::env::current_exe()?.to_string_lossy().into_owned(); + // A process with sandbox intent needs an enforceable profile and an enabled Windows backend. + let process_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Path { path: cwd.clone() }, + FileSystemAccessMode::Write, + ), + ]); + let mut process_sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&process_policy, NetworkSandboxPolicy::Enabled), + cwd.clone(), + ); + process_sandbox.windows_sandbox_selection = WindowsSandboxSelection::RestrictedToken; + let sandbox = + FileSystemSandboxContext::from_permission_profile(PermissionProfile::Disabled, cwd.clone()); + let requests = [ + ( + EXEC_METHOD, + serde_json::json!({ + "processId": "sandbox-cwd", "argv": [program, "--help"], "cwd": cwd, + "env": {}, "tty": false, "sandbox": process_sandbox, + }), + "/sandbox", + ), + ( + FS_READ_FILE_METHOD, + serde_json::json!({ "path": path, "sandbox": sandbox }), + "/sandbox", + ), + ( + CAPABILITY_ROOTS_DISCOVER_METHOD, + serde_json::json!({ "roots": [{ "id": "root", "path": cwd, "sandbox": sandbox }] }), + "/roots/0/sandbox", + ), + ]; + let mut server = exec_server().await?; + initialize(&mut server).await?; + + for (method, request, sandbox_pointer) in requests { + for (index, legacy_cwd) in [None, Some(serde_json::Value::Null)] + .into_iter() + .enumerate() + { + let mut request = request.clone(); + if method == EXEC_METHOD { + request["processId"] = serde_json::json!(format!("sandbox-cwd-{index}")); + } + let sandbox = request + .pointer_mut(sandbox_pointer) + .and_then(serde_json::Value::as_object_mut) + .expect("sandbox request"); + if let Some(legacy_cwd) = legacy_cwd { + sandbox.insert("cwd".to_string(), legacy_cwd); + } else { + sandbox.remove("cwd"); + } + sandbox.remove("workspaceRoots"); + let request_id = server.send_request(method, request).await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + let JSONRPCMessage::Response(response) = response else { + anyhow::bail!("expected legacy {method} to succeed, got {response:?}"); + }; + if method == FS_READ_FILE_METHOD { + let read: FsReadFileResponse = serde_json::from_value(response.result)?; + assert_eq!(STANDARD.decode(read.data_base64)?, b"contents"); + } else if method == CAPABILITY_ROOTS_DISCOVER_METHOD { + let discovery: CapabilityRootsDiscoverResponse = + serde_json::from_value(response.result)?; + assert_eq!( + discovery.roots[0] + .skills + .iter() + .map(|skill| &skill.instructions.path) + .collect::>(), + vec![&cwd.join("skills/demo/SKILL.md")?], + ); + } + } + } + + server.shutdown().await?; + Ok(()) +} + +/// Old filesystem clients cannot replace the original cwd for rules whose meaning depends on it. +#[tokio::test] +async fn executor_legacy_filesystem_rejects_missing_cwd_for_dynamic_permissions() +-> anyhow::Result<()> { + let workspace = tempfile::tempdir()?; + let cwd = PathUri::from_host_native_path(workspace.path())?; + let mut server = exec_server().await?; + initialize(&mut server).await?; + + for path in [ + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath: None }, + }, + FileSystemPath::GlobPattern { + pattern: "secrets/**".to_string(), + }, + ] { + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( + path, + FileSystemAccessMode::Deny, + )]); + let sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + for legacy_cwd in [None, Some(serde_json::Value::Null)] { + let mut sandbox = serde_json::to_value(&sandbox)?; + let fields = sandbox.as_object_mut().expect("sandbox object"); + if let Some(legacy_cwd) = legacy_cwd { + fields.insert("cwd".to_string(), legacy_cwd); + } else { + fields.remove("cwd"); + } + fields.remove("workspaceRoots"); + for (method, request) in [ + ( + FS_READ_FILE_METHOD, + serde_json::json!({ "path": cwd, "sandbox": sandbox }), + ), + ( + FS_OPEN_METHOD, + serde_json::json!({ "handleId": "denied", "path": cwd, "sandbox": sandbox }), + ), + ( + CAPABILITY_ROOTS_DISCOVER_METHOD, + serde_json::json!({ "roots": [{ "id": "root", "path": cwd, "sandbox": sandbox }] }), + ), + ] { + let request_id = server.send_request(method, request).await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + let JSONRPCMessage::Error(error) = response else { + anyhow::bail!( + "expected legacy {method} to reject dynamic permissions, got {response:?}" + ); + }; + assert_eq!( + (error.error.code, error.error.message.as_str()), + ( + -32602, + "file system sandbox context with dynamic permissions requires cwd" + ), + "{method}", + ); + } + } + } + + server.shutdown().await?; + Ok(()) +} + +/// Providing cwd lets project roots and relative rules be enforced against the original directory. +#[cfg(unix)] +#[tokio::test] +async fn executor_legacy_filesystem_accepts_cwd_for_dynamic_permissions() -> anyhow::Result<()> { + let workspace = tempfile::tempdir()?; + write_file(&workspace.path().join("allowed.txt"), "allowed")?; + write_file(&workspace.path().join("secret.txt"), "secret")?; + let cwd = PathUri::from_host_native_path(workspace.path())?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath: None }, + }, + FileSystemAccessMode::Write, + ), + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: "secret*".to_string(), + }, + FileSystemAccessMode::Deny, + ), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + let mut server = exec_server().await?; + initialize(&mut server).await?; + let request_id = server + .send_request( + FS_READ_FILE_METHOD, + serde_json::json!({"path": cwd.join("secret.txt")?}), + ) + .await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + let JSONRPCMessage::Response(response) = response else { + anyhow::bail!( + "expected the denied path to be readable without a sandbox, got {response:?}" + ); + }; + let read: FsReadFileResponse = serde_json::from_value(response.result)?; + assert_eq!(STANDARD.decode(read.data_base64)?, b"secret"); + + for (name, permitted) in [("allowed.txt", true), ("secret.txt", false)] { + let request_id = server + .send_request( + FS_READ_FILE_METHOD, + serde_json::json!({ + "path": cwd.join(name)?, "sandbox": sandbox, + }), + ) + .await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + match response { + JSONRPCMessage::Response(response) if permitted => { + let read: FsReadFileResponse = serde_json::from_value(response.result)?; + assert_eq!(STANDARD.decode(read.data_base64)?, b"allowed"); + } + JSONRPCMessage::Error(error) if !permitted => { + assert_eq!(error.error.code, -32600, "{error:?}"); + assert!( + matches!( + error.error.message.as_str(), + "Permission denied (os error 13)" | "Operation not permitted (os error 1)" + ), + "expected a filesystem permission denial: {error:?}" + ); + } + response => anyhow::bail!("unexpected filesystem result for {name}: {response:?}"), + } + } + server.shutdown().await?; + Ok(()) +} + +/// A present policy context can omit cwd: legacy cwd still anchors denials, and only static +/// filesystem policies can use the executor default when both cwd fields are absent. +#[tokio::test] +async fn executor_legacy_nested_policy_context_cwd_uses_available_fallbacks() -> anyhow::Result<()> +{ + let workspace = tempfile::tempdir()?; + write_file(&workspace.path().join("allowed.txt"), "allowed")?; + write_file(&workspace.path().join("secret.txt"), "secret")?; + let cwd = PathUri::from_host_native_path(workspace.path())?; + let static_permissions = if cfg!(unix) { + PermissionProfile::read_only() + } else { + // The direct Windows test fixture has no enabled platform sandbox. + PermissionProfile::Disabled + }; + let static_sandbox = + FileSystemSandboxContext::from_permission_profile(static_permissions, cwd.clone()); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: "secret*".to_string(), + }, + FileSystemAccessMode::Deny, + ), + ]); + let dynamic_sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + let mut server = exec_server().await?; + initialize(&mut server).await?; + + for (shape, policy_context) in [ + ("missing", serde_json::json!({"workspaceRoots": [cwd]})), + ( + "null", + serde_json::json!({"cwd": null, "workspaceRoots": [cwd]}), + ), + ] { + for (case, sandbox, legacy_cwd, name, expected) in [ + ( + "static allowed without legacy cwd", + &static_sandbox, + None::<&PathUri>, + "allowed.txt", + Ok(b"allowed".as_slice()), + ), + ( + "static secret without legacy cwd", + &static_sandbox, + None, + "secret.txt", + Ok(b"secret".as_slice()), + ), + ( + "dynamic without legacy cwd", + &dynamic_sandbox, + None, + "allowed.txt", + Err(-32602), + ), + // The default Windows test backend cannot enforce read denials at runtime. + #[cfg(unix)] + ( + "dynamic allowed with legacy cwd", + &dynamic_sandbox, + Some(&cwd), + "allowed.txt", + Ok(b"allowed".as_slice()), + ), + #[cfg(unix)] + ( + "dynamic secret with legacy cwd", + &dynamic_sandbox, + Some(&cwd), + "secret.txt", + Err(-32600), + ), + ] { + let mut sandbox = serde_json::to_value(sandbox)?; + let fields = sandbox.as_object_mut().expect("sandbox object"); + fields.insert("policyContext".to_string(), policy_context.clone()); + if let Some(legacy_cwd) = legacy_cwd { + fields.insert("cwd".to_string(), serde_json::to_value(legacy_cwd)?); + } else { + fields.remove("cwd"); + } + fields.remove("workspaceRoots"); + let request_id = server + .send_request( + FS_READ_FILE_METHOD, + serde_json::json!({"path": cwd.join(name)?, "sandbox": sandbox}), + ) + .await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + match (response, expected) { + (JSONRPCMessage::Response(response), Ok(contents)) => { + let read: FsReadFileResponse = serde_json::from_value(response.result)?; + assert_eq!( + STANDARD.decode(read.data_base64)?, + contents, + "{shape}: {case}" + ); + } + (JSONRPCMessage::Error(error), Err(-32602)) => assert_eq!( + (error.error.code, error.error.message.as_str()), + ( + -32602, + "file system sandbox context with dynamic permissions requires cwd" + ), + "{shape}: {case}", + ), + (JSONRPCMessage::Error(error), Err(-32600)) => { + assert_eq!(error.error.code, -32600, "{shape}: {case}: {error:?}"); + assert!( + matches!( + error.error.message.as_str(), + "Permission denied (os error 13)" + | "Operation not permitted (os error 1)" + ), + "{shape}: {case}: {error:?}", + ); + } + (response, expected) => anyhow::bail!( + "unexpected {shape} nested cwd result for {case}: expected {expected:?}, got {response:?}" + ), + } + } + } + + server.shutdown().await?; + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn executor_legacy_exec_uses_process_cwd_for_relative_denials() -> anyhow::Result<()> { + let workspace = tempfile::tempdir()?; + std::fs::write(workspace.path().join("allowed.txt"), b"allowed")?; + std::fs::write(workspace.path().join("secret.txt"), b"secret")?; + let cwd = PathUri::from_host_native_path(workspace.path())?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: "secret*".to_string(), + }, + FileSystemAccessMode::Deny, + ), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + let process_env = + std::collections::HashMap::from([("PATH".to_string(), std::env::var("PATH")?)]); + #[cfg(target_os = "linux")] + let process_env = { + let mut process_env = process_env; + // Bazel-provided bubblewrap lives in runfiles, not necessarily on the process PATH. + for name in [ + "CARGO_BIN_EXE_bwrap", + "RUNFILES_DIR", + "RUNFILES_MANIFEST_FILE", + "TEST_SRCDIR", + "TEST_WORKSPACE", + ] { + if let Ok(value) = std::env::var(name) { + process_env.insert(name.to_string(), value); + } + } + process_env + }; + let mut server = exec_server().await?; + initialize(&mut server).await?; + for (index, legacy_cwd) in [None, Some(serde_json::Value::Null)] + .into_iter() + .enumerate() + { + let mut sandbox = serde_json::to_value(&sandbox)?; + let fields = sandbox.as_object_mut().expect("sandbox object"); + if let Some(legacy_cwd) = legacy_cwd { + fields.insert("cwd".to_string(), legacy_cwd); + } else { + fields.remove("cwd"); + } + let process = format!("legacy-relative-{index}"); + let request_id = server.send_request(EXEC_METHOD, serde_json::json!({ + "processId": process, "argv": ["/bin/sh", "-c", "cat allowed.txt && if cat secret.txt 2>/dev/null; then exit 9; fi"], "cwd": cwd, + "env": process_env, "tty": false, "sandbox": sandbox, + })).await?; + let start = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + if !matches!(start, JSONRPCMessage::Response(_)) { + anyhow::bail!("expected legacy process to start, got {start:?}"); + } + let mut after_seq = None; + let mut output = Vec::new(); + let (exit_code, failure, sandbox_denied) = loop { + let request_id = server + .send_request( + EXEC_READ_METHOD, + serde_json::json!({ + "processId": process, "afterSeq": after_seq, "waitMs": 1000, + }), + ) + .await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + let JSONRPCMessage::Response(response) = response else { + anyhow::bail!("expected legacy process output, got {response:?}"); + }; + let response: ReadResponse = serde_json::from_value(response.result)?; + output.extend(response.chunks.into_iter().flat_map(|chunk| chunk.chunk.0)); + after_seq = response.next_seq.checked_sub(1).or(after_seq); + if response.closed { + break ( + response.exit_code, + response.failure, + response.sandbox_denied, + ); + } + }; + assert_eq!( + (exit_code, String::from_utf8(output)?), + (Some(0), "allowed".to_string()), + "legacy process failed: {failure:?}; sandbox denied: {sandbox_denied}" + ); + } + server.shutdown().await?; + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn executor_legacy_filesystem_cwd_keeps_absolute_read_allow_and_deny_rules() +-> anyhow::Result<()> { + let workspace = tempfile::tempdir()?; + let allowed = workspace.path().join("allowed"); + let denied = workspace.path().join("denied"); + for (root, name) in [(&allowed, "allowed"), (&denied, "denied")] { + write_file(&root.join("note.txt"), "contents")?; + write_file( + &root.join(format!("skills/{name}/SKILL.md")), + &format!("---\nname: {name}\ndescription: {name}.\n---\n"), + )?; + } + let allowed = PathUri::from_host_native_path(allowed)?; + let denied = PathUri::from_host_native_path(denied)?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: denied.clone(), + }, + FileSystemAccessMode::Deny, + ), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + PathUri::from_host_native_path(workspace.path())?, + ); + let mut server = exec_server().await?; + initialize(&mut server).await?; + + let request_id = server + .send_request( + FS_READ_FILE_METHOD, + serde_json::json!({"path": denied.join("note.txt")?}), + ) + .await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + let JSONRPCMessage::Response(response) = response else { + anyhow::bail!( + "expected the denied path to be readable without a sandbox, got {response:?}" + ); + }; + let read: FsReadFileResponse = serde_json::from_value(response.result)?; + assert_eq!(STANDARD.decode(read.data_base64)?, b"contents"); + + for (index, legacy_cwd) in [None, Some(serde_json::Value::Null)] + .into_iter() + .enumerate() + { + let mut sandbox = serde_json::to_value(&sandbox)?; + let fields = sandbox.as_object_mut().expect("sandbox object"); + if let Some(legacy_cwd) = legacy_cwd { + fields.insert("cwd".to_string(), legacy_cwd); + } else { + fields.remove("cwd"); + } + fields.remove("workspaceRoots"); + for (root, permitted) in [(&allowed, true), (&denied, false)] { + let handle = format!("legacy-{index}-{permitted}"); + for (method, request) in [ + ( + FS_READ_FILE_METHOD, + serde_json::json!({ "path": root.join("note.txt")?, "sandbox": sandbox }), + ), + ( + FS_OPEN_METHOD, + serde_json::json!({ "handleId": handle, "path": root.join("note.txt")?, "sandbox": sandbox }), + ), + ] { + let request_id = server.send_request(method, request).await?; + let response = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + match response { + JSONRPCMessage::Response(response) if permitted => { + if method == FS_READ_FILE_METHOD { + let read: FsReadFileResponse = serde_json::from_value(response.result)?; + assert_eq!(STANDARD.decode(read.data_base64)?, b"contents"); + } else { + let request_id = server.send_request(FS_READ_BLOCK_METHOD, serde_json::json!({ "handleId": handle, "offset": 0, "len": 16 })).await?; + let block = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) + | JSONRPCMessage::Notification(_) => false, + }) + .await?; + let JSONRPCMessage::Response(block) = block else { + anyhow::bail!("expected to read the legacy stream, got {block:?}"); + }; + let block: FsReadBlockResponse = serde_json::from_value(block.result)?; + assert_eq!(block.chunk.0, b"contents"); + } + } + JSONRPCMessage::Error(error) if !permitted => { + assert_eq!(error.error.code, -32600, "{method}: {error:?}"); + assert!( + matches!( + error.error.message.as_str(), + "Permission denied (os error 13)" + | "Operation not permitted (os error 1)" + ), + "expected a filesystem permission denial for {method}: {error:?}" + ); + } + response => { + anyhow::bail!("unexpected legacy {method} result for {root}: {response:?}") + } + } + } + } + let request_id = server + .send_request( + CAPABILITY_ROOTS_DISCOVER_METHOD, + serde_json::json!({ "roots": [ + { "id": "allowed", "path": allowed, "sandbox": sandbox }, + { "id": "denied", "path": denied, "sandbox": sandbox }, + ] }), + ) + .await?; + let discovery = server + .wait_for_event(|event| match event { + JSONRPCMessage::Response(response) => response.id == request_id, + JSONRPCMessage::Error(error) => error.id == request_id, + JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false, + }) + .await?; + let JSONRPCMessage::Response(discovery) = discovery else { + anyhow::bail!("expected legacy capability discovery, got {discovery:?}"); + }; + let discovery: CapabilityRootsDiscoverResponse = serde_json::from_value(discovery.result)?; + assert_eq!( + discovery + .roots + .iter() + .map(|root| ( + root.id.as_str(), + root.skills + .iter() + .map(|skill| &skill.instructions.path) + .collect::>() + )) + .collect::>(), + vec![ + ("allowed", vec![&allowed.join("skills/allowed/SKILL.md")?]), + ("denied", vec![]), + ] + ); + } + + server.shutdown().await?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sandboxed_discovery_batches_roots_without_combining_different_permissions() -> anyhow::Result<()> { @@ -208,7 +933,7 @@ async fn sandboxed_discovery_batches_roots_without_combining_different_permissio FileSystemAccessMode::Read, ); let policy = FileSystemSandboxPolicy::restricted(vec![read_workspace]); - let shared_sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let shared_sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), workspace_uri, ); @@ -328,7 +1053,7 @@ async fn sandboxed_discovery_batches_roots_without_combining_different_permissio FileSystemAccessMode::Read, ); let first_policy = FileSystemSandboxPolicy::restricted(vec![read_first_root]); - let first_only_sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let first_only_sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions( &first_policy, NetworkSandboxPolicy::Restricted, @@ -341,7 +1066,7 @@ async fn sandboxed_discovery_batches_roots_without_combining_different_permissio FileSystemAccessMode::Read, ); let second_policy = FileSystemSandboxPolicy::restricted(vec![read_second_root]); - let second_only_sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let second_only_sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions( &second_policy, NetworkSandboxPolicy::Restricted, @@ -441,7 +1166,7 @@ async fn sandboxed_discovery_follows_only_permitted_external_symlinks() -> anyho for (scenario, entries, has_skill, has_mcp) in cases { let policy = FileSystemSandboxPolicy::restricted(entries); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), root_uri.clone(), ); diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index b4e861d50b91..095cbe25e119 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -140,7 +140,7 @@ async fn codex_home_symlink_opt_out_respects_host_config_and_scope() -> Result<( PathUri::from_host_native_path(root)?.into(), FileSystemAccessMode::Write, )); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions( &policy, NetworkSandboxPolicy::Restricted, @@ -345,7 +345,7 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory( pipe_stdin: false, arg0: (shell_name == "bash-sh").then(|| "sh".to_string()), sandbox: (use_sandbox && attempt == 0).then(|| { - FileSystemSandboxContext::from_permission_profile_with_cwd( + FileSystemSandboxContext::from_permission_profile( PermissionProfile::read_only(), cwd.clone(), ) @@ -522,7 +522,7 @@ async fn shell_snapshot_v2_capture_failure_falls_back_and_retries( pipe_stdin: false, arg0: None, sandbox: use_remote.then(|| { - FileSystemSandboxContext::from_permission_profile_with_cwd( + FileSystemSandboxContext::from_permission_profile( PermissionProfile::workspace_write(), cwd, ) @@ -603,7 +603,7 @@ async fn remote_sandboxed_process_preserves_custom_arg0() -> Result<()> { missing_path_behavior: None, }, ]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), cwd.clone(), ); @@ -706,7 +706,7 @@ async fn remote_process_keeps_sandbox_helper_visible_with_restricted_reads() -> missing_path_behavior: None, }, ]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), cwd.clone(), ); @@ -777,7 +777,7 @@ async fn remote_tty_process_uses_configured_sandbox_helper_with_hostile_path() - missing_path_behavior: None, }, ]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), cwd.clone(), ); @@ -833,7 +833,7 @@ async fn remote_process_preserves_empty_workspace_roots() -> Result<()> { access: FileSystemAccessMode::Read, missing_path_behavior: None, }]); - let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let mut sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), cwd.clone(), ); diff --git a/codex-rs/exec-server/tests/exec_process/windows_sandbox.rs b/codex-rs/exec-server/tests/exec_process/windows_sandbox.rs index f7e725ff9ec4..d001687a93b3 100644 --- a/codex-rs/exec-server/tests/exec_process/windows_sandbox.rs +++ b/codex-rs/exec-server/tests/exec_process/windows_sandbox.rs @@ -46,7 +46,7 @@ async fn mxc_tmpdir_uses_command_environment_over_rpc() -> Result<()> { FileSystemAccessMode::Write, ), ]); - let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let mut sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&fs, NetworkSandboxPolicy::Restricted), cwd.clone(), ); diff --git a/codex-rs/exec-server/tests/file_stream.rs b/codex-rs/exec-server/tests/file_stream.rs index 9fd3201eebf1..5de1b5699175 100644 --- a/codex-rs/exec-server/tests/file_stream.rs +++ b/codex-rs/exec-server/tests/file_stream.rs @@ -373,12 +373,14 @@ fn read_only_sandbox(path: std::path::PathBuf) -> codex_exec_server::FileSystemS let path = AbsolutePathBuf::from_absolute_path(&path) .unwrap_or_else(|err| panic!("sandbox path should be absolute: {err}")); - FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( + let cwd = PathUri::from_abs_path(&path); + let permissions = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: path.into(), access: FileSystemAccessMode::Read, missing_path_behavior: None, }]), NetworkSandboxPolicy::Restricted, - )) + ); + FileSystemSandboxContext::from_permission_profile(permissions, cwd) } diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index 96dfa143492f..928d65cd7a6e 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -48,8 +48,7 @@ fn sandbox_context_from_profile_preserves_workspace_write_read_only_subpaths() - std::fs::create_dir_all(&git_dir)?; let sandbox = workspace_write_sandbox(writable_dir.clone()); - let permissions: PermissionProfile = sandbox.permissions.try_into()?; - let policy = permissions.file_system_sandbox_policy(); + let policy = sandbox.permissions.file_system_sandbox_policy(); let cwd = absolute_path(writable_dir.clone()); let writable_roots = policy.get_writable_roots_with_cwd(cwd.as_path()); let writable_dir = absolute_path(std::fs::canonicalize(writable_dir)?); @@ -1080,21 +1079,20 @@ async fn file_system_sandboxed_write_allows_additional_write_root( Some(vec![absolute_path(writable_dir)]), )), }; - let native_permissions: PermissionProfile = sandbox.permissions.clone().try_into()?; + let permissions = &sandbox.permissions; let file_system_policy = effective_file_system_sandbox_policy( - &native_permissions.file_system_sandbox_policy(), + &permissions.file_system_sandbox_policy(), Some(&additional_permissions), ); let network_policy = effective_network_sandbox_policy( - native_permissions.network_sandbox_policy(), + permissions.network_sandbox_policy(), Some(&additional_permissions), ); sandbox.permissions = PermissionProfile::from_runtime_permissions_with_enforcement( - native_permissions.enforcement(), + permissions.enforcement(), &file_system_policy, network_policy, - ) - .into(); + ); file_system .write_file( diff --git a/codex-rs/exec-server/tests/file_system/support.rs b/codex-rs/exec-server/tests/file_system/support.rs index 34125c24aeed..4db701d29e84 100644 --- a/codex-rs/exec-server/tests/file_system/support.rs +++ b/codex-rs/exec-server/tests/file_system/support.rs @@ -16,7 +16,6 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; -#[cfg(windows)] use codex_utils_path_uri::PathUri; use crate::common::exec_server::ExecServerHarness; @@ -93,13 +92,15 @@ pub(crate) fn absolute_path(path: std::path::PathBuf) -> AbsolutePathBuf { pub(crate) fn read_only_sandbox(readable_root: std::path::PathBuf) -> FileSystemSandboxContext { let readable_root = absolute_path(readable_root); - sandbox_context(vec![FileSystemSandboxEntry { + let cwd = PathUri::from_abs_path(&readable_root); + let entries = vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: readable_root.into(), }, access: FileSystemAccessMode::Read, missing_path_behavior: None, - }]) + }]; + sandbox_context(entries, cwd) } #[cfg(not(windows))] @@ -107,13 +108,15 @@ pub(crate) fn workspace_write_sandbox( writable_root: std::path::PathBuf, ) -> FileSystemSandboxContext { let writable_root = absolute_path(writable_root); - sandbox_context(vec![FileSystemSandboxEntry { + let cwd = PathUri::from_abs_path(&writable_root); + let entries = vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: writable_root.into(), }, access: FileSystemAccessMode::Write, missing_path_behavior: None, - }]) + }]; + sandbox_context(entries, cwd) } #[cfg(windows)] @@ -137,7 +140,7 @@ pub(crate) fn workspace_write_sandbox( FileSystemAccessMode::Write, ), ]); - let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let mut sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), PathUri::from_abs_path(&writable_root), ); @@ -145,7 +148,10 @@ pub(crate) fn workspace_write_sandbox( sandbox } -fn sandbox_context(mut entries: Vec) -> FileSystemSandboxContext { +fn sandbox_context( + mut entries: Vec, + cwd: PathUri, +) -> FileSystemSandboxContext { if cfg!(windows) { // Restricted-token sandboxing cannot enforce read restrictions, so leave the root // readable while exercising the requested write restrictions. @@ -161,6 +167,7 @@ fn sandbox_context(mut entries: Vec) -> FileSystemSandbo &FileSystemSandboxPolicy::restricted(entries), NetworkSandboxPolicy::Restricted, ), + cwd, ); if cfg!(windows) { sandbox.windows_sandbox_selection = WindowsSandboxSelection::RestrictedToken; diff --git a/codex-rs/exec-server/tests/file_system_unix.rs b/codex-rs/exec-server/tests/file_system_unix.rs index 4b5615611f3e..6e45cd2dfcd7 100644 --- a/codex-rs/exec-server/tests/file_system_unix.rs +++ b/codex-rs/exec-server/tests/file_system_unix.rs @@ -588,7 +588,7 @@ async fn remote_read_file_materializes_environment_workspace_roots() -> Result<( access: FileSystemAccessMode::Read, missing_path_behavior: None, }]); - let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let mut sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), PathUri::from_host_native_path(tmp.path())?, ); @@ -632,7 +632,7 @@ async fn remote_read_file_preserves_empty_workspace_roots() -> Result<()> { access: FileSystemAccessMode::Read, missing_path_behavior: None, }]); - let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let mut sandbox = FileSystemSandboxContext::from_permission_profile( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), PathUri::from_host_native_path(tmp.path())?, ); diff --git a/codex-rs/exec-server/tests/file_system_windows.rs b/codex-rs/exec-server/tests/file_system_windows.rs index 7fe9df0f8ec9..7bc41bfe7c7e 100644 --- a/codex-rs/exec-server/tests/file_system_windows.rs +++ b/codex-rs/exec-server/tests/file_system_windows.rs @@ -14,6 +14,7 @@ use std::path::Path; use std::process::Command; use std::time::Duration; +use anyhow::Context as _; use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::FileSystemSandboxContext; @@ -22,6 +23,13 @@ use codex_exec_server::ReadFileOptions; use codex_exec_server::RemoveOptions; use codex_exec_server::WindowsSandboxSelection; use codex_exec_server::WriteFileOptions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_utils_path_uri::PathUri; @@ -430,6 +438,118 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy( Ok(()) } +/// An elevated filesystem helper must enforce relative deny globs from the policy cwd. +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial_test::serial(remote_exec_server)] +async fn file_system_elevated_relative_read_denial_uses_policy_cwd( + implementation: FileSystemImplementation, +) -> Result<()> { + // Both implementations re-enter this test binary; the elevated backend finds its helpers + // next to that binary, while Cargo and Bazel provide them separately. + let test_exe = std::env::current_exe()?; + let resources = test_exe + .parent() + .context("Windows test executable should have a parent directory")? + .join("codex-resources"); + if let Err(error) = std::fs::create_dir_all(&resources) + && !(error.kind() == std::io::ErrorKind::PermissionDenied && resources.is_dir()) + { + return Err(error).context("create Windows sandbox test resources"); + } + for name in ["codex-windows-sandbox-setup", "codex-command-runner"] { + let source = codex_utils_cargo_bin::cargo_bin(name)?; + let destination = resources.join(Path::new(name).with_extension("exe")); + if let Err(error) = std::fs::copy(&source, &destination) + && !(error.kind() == std::io::ErrorKind::PermissionDenied && destination.is_file()) + { + return Err(error).with_context(|| format!("stage Windows sandbox helper {name}")); + } + } + let context = create_file_system_context(implementation).await?; + let tmp = tempfile::TempDir::new()?; + let policy_cwd = tmp.path().join("checkout"); + let selected_files = policy_cwd.join("files"); + let other_files = tmp.path().join("files"); + std::fs::create_dir_all(&selected_files)?; + std::fs::create_dir(&other_files)?; + let allowed_neighbor = selected_files.join("allowed.txt"); + let denied = selected_files.join("blocked.env"); + let same_name_outside = other_files.join("blocked.env"); + std::fs::write(&allowed_neighbor, b"allowed neighbor")?; + std::fs::write(&denied, b"denied")?; + std::fs::write(&same_name_outside, b"allowed outside")?; + + let cwd = PathUri::from_host_native_path(&policy_cwd)?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(tmp.path())?, + }, + FileSystemAccessMode::Write, + ), + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: "files/*.env".to_string(), + }, + FileSystemAccessMode::Deny, + ), + ]); + let mut sandbox = FileSystemSandboxContext::from_permission_profile( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd, + ); + sandbox.windows_sandbox_selection = WindowsSandboxSelection::Elevated; + + let file_system = &context.file_system; + let allowed_neighbor = file_system + .read_file( + &PathUri::from_host_native_path(&allowed_neighbor)?, + ReadFileOptions::default(), + Some(&sandbox), + ) + .await?; + let same_name_outside = file_system + .read_file( + &PathUri::from_host_native_path(&same_name_outside)?, + ReadFileOptions::default(), + Some(&sandbox), + ) + .await?; + let denied = file_system + .read_file( + &PathUri::from_host_native_path(&denied)?, + ReadFileOptions::default(), + Some(&sandbox), + ) + .await + .expect_err("read matching the policy-cwd denial must be rejected"); + assert_eq!( + ( + allowed_neighbor.as_slice(), + same_name_outside.as_slice(), + denied.kind(), + ), + ( + b"allowed neighbor".as_slice(), + b"allowed outside".as_slice(), + std::io::ErrorKind::InvalidInput, + ) + ); + assert!( + denied.to_string().contains("Access is denied"), + "expected Windows access denial, got: {denied}" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn file_system_private_desktop_survives_helper_exits_and_separates_permissions() -> Result<()> { diff --git a/codex-rs/ext/skills/tests/executor_file_system_authority.rs b/codex-rs/ext/skills/tests/executor_file_system_authority.rs index 1f0f0e6ae660..ffdedc6e2f79 100644 --- a/codex-rs/ext/skills/tests/executor_file_system_authority.rs +++ b/codex-rs/ext/skills/tests/executor_file_system_authority.rs @@ -312,6 +312,7 @@ async fn windows_executor_skill_read_requires_a_requested_sandbox() { &FileSystemSandboxPolicy::restricted(Vec::new()), NetworkSandboxPolicy::Restricted, ), + PathUri::parse("file:///C:/skill").expect("Windows resource cwd"), ); let resource = SkillResourceId::environment( "skill://windows-root/C:/skill/SKILL.md", @@ -978,7 +979,10 @@ async fn high_level_discovery_cache_separates_filesystem_permission_contexts() { .expect("update executor skill"); let sandbox_contexts = HashMap::from([( "local".to_string(), - FileSystemSandboxContext::from_permission_profile(PermissionProfile::Disabled), + FileSystemSandboxContext::from_permission_profile( + PermissionProfile::Disabled, + PathUri::from_host_native_path(&test_root).expect("skill root URI"), + ), )]); let second_snapshot = cache.snapshot(&executor_roots, &sandbox_contexts).await; let second_discovery = second_snapshot.roots()[0] diff --git a/codex-rs/file-system/src/exec_permission_profile_serde.rs b/codex-rs/file-system/src/exec_permission_profile_serde.rs new file mode 100644 index 000000000000..f0f29896b3b0 --- /dev/null +++ b/codex-rs/file-system/src/exec_permission_profile_serde.rs @@ -0,0 +1,22 @@ +//! Uses executor file URIs for sandbox permissions instead of the profile's legacy native paths. + +use crate::ExecPermissionProfile; +use codex_protocol::models::PermissionProfile; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; + +pub(crate) fn serialize(value: &PermissionProfile, serializer: S) -> Result +where + S: Serializer, +{ + ExecPermissionProfile::from(value.clone()).serialize(serializer) +} + +pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + ExecPermissionProfile::deserialize(deserializer).map(PermissionProfile::from) +} diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index ba825c5ce811..0abe9dba3d86 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -1,3 +1,4 @@ +mod exec_permission_profile_serde; mod find_up; use bytes::Bytes; @@ -16,6 +17,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicyContext; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; pub use find_up::FindUpErrorPolicy; pub use find_up::find_nearest_ancestor_with_markers; @@ -187,18 +189,13 @@ impl From for ExecFileSystemPath { } } -impl TryFrom for FileSystemPath { - type Error = io::Error; - - fn try_from(value: ExecFileSystemPath) -> Result { - Ok(match value { - ExecFileSystemPath::Path { path } => { - path.to_abs_path()?; - Self::Path { path } - } +impl From for FileSystemPath { + fn from(value: ExecFileSystemPath) -> Self { + match value { + ExecFileSystemPath::Path { path } => Self::Path { path }, ExecFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, ExecFileSystemPath::Special { value } => Self::Special { value }, - }) + } } } @@ -220,15 +217,13 @@ impl From for ExecFileSystemSandboxEntry { } } -impl TryFrom for FileSystemSandboxEntry { - type Error = io::Error; - - fn try_from(value: ExecFileSystemSandboxEntry) -> Result { - Ok(Self { - path: value.path.try_into()?, +impl From for FileSystemSandboxEntry { + fn from(value: ExecFileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), access: value.access, missing_path_behavior: value.missing_path_behavior, - }) + } } } @@ -258,26 +253,22 @@ impl From for ExecManagedFileSystemPermissions { } } -impl TryFrom for ManagedFileSystemPermissions { - type Error = io::Error; - - fn try_from(value: ExecManagedFileSystemPermissions) -> Result { - Ok(match value { +impl From for ManagedFileSystemPermissions { + fn from(value: ExecManagedFileSystemPermissions) -> Self { + match value { ExecManagedFileSystemPermissions::Restricted { entries, glob_scan_max_depth, } => Self::Restricted { - entries: entries - .into_iter() - .map(TryInto::try_into) - .collect::>()?, + entries: entries.into_iter().map(Into::into).collect(), glob_scan_max_depth, }, ExecManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, - }) + } } } +/// Executor permission profile whose explicit filesystem paths serialize as file URIs. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ExecPermissionProfile { @@ -307,21 +298,19 @@ impl From for ExecPermissionProfile { } } -impl TryFrom for PermissionProfile { - type Error = io::Error; - - fn try_from(value: ExecPermissionProfile) -> Result { - Ok(match value { +impl From for PermissionProfile { + fn from(value: ExecPermissionProfile) -> Self { + match value { ExecPermissionProfile::Managed { file_system, network, } => Self::Managed { - file_system: file_system.try_into()?, + file_system: file_system.into(), network, }, ExecPermissionProfile::Disabled => Self::Disabled, ExecPermissionProfile::External { network } => Self::External { network }, - }) + } } } @@ -349,12 +338,16 @@ impl From for WindowsSandboxSelection { } } +/// Filesystem sandbox policy and the selected executor paths needed to interpret it. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileSystemSandboxContext { - pub permissions: ExecPermissionProfile, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, + /// Serializes paths as executor file URIs instead of the profile's default native paths. + #[serde(with = "exec_permission_profile_serde")] + pub permissions: PermissionProfile, + /// Working directory on the selected executor used to interpret sandbox permissions. + /// Required even for absolute permissions; a process may use a different working directory. + pub cwd: PathUri, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub workspace_roots: Vec, /// Executor-local user home used to resolve home-relative policy paths. @@ -391,23 +384,14 @@ impl FileSystemSandboxContext { &file_system_sandbox_policy, NetworkSandboxPolicy::from(&sandbox_policy), ); - Ok(Self::from_permission_profile_with_cwd(permissions, cwd)) - } - - pub fn from_permission_profile(permissions: PermissionProfile) -> Self { - Self::from_permissions_and_cwd(permissions, /*cwd*/ None) + Ok(Self::from_permission_profile(permissions, cwd)) } - pub fn from_permission_profile_with_cwd(permissions: PermissionProfile, cwd: PathUri) -> Self { - Self::from_permissions_and_cwd(permissions, Some(cwd)) - } - - fn from_permissions_and_cwd(permissions: PermissionProfile, cwd: Option) -> Self { - let workspace_roots = cwd.iter().cloned().collect(); + pub fn from_permission_profile(permissions: PermissionProfile, cwd: PathUri) -> Self { Self { - permissions: permissions.into(), + permissions, + workspace_roots: vec![cwd.clone()], cwd, - workspace_roots, user_home_dir: None, temporary_directories: None, windows_sandbox_selection: WindowsSandboxSelection::Disabled, @@ -418,11 +402,11 @@ impl FileSystemSandboxContext { } pub fn should_run_in_sandbox(&self) -> bool { - let Ok(permissions) = PermissionProfile::try_from(self.permissions.clone()) else { + if self.validate_file_system_paths_for_current_host().is_err() { // A sandbox context for another host must not select the unsandboxed filesystem. return true; - }; - let file_system_policy = permissions.file_system_sandbox_policy(); + } + let file_system_policy = self.permissions.file_system_sandbox_policy(); matches!(file_system_policy.kind, FileSystemSandboxKind::Restricted) && !file_system_policy.has_full_disk_write_access() } @@ -432,25 +416,101 @@ impl FileSystemSandboxContext { self.windows_sandbox_selection != WindowsSandboxSelection::Disabled } + /// Checks that explicit permission paths can be enforced by the current host. An + /// orchestrator can still construct this context with paths belonging to another executor. + pub fn validate_file_system_paths_for_current_host(&self) -> io::Result<()> { + if let PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, + .. + } = &self.permissions + { + for entry in entries { + if let FileSystemPath::Path { path } = &entry.path { + path.to_abs_path().map_err(|error| { + io::Error::new( + error.kind(), + format!("invalid sandbox permission path URI: {error}"), + ) + })?; + } + } + } + Ok(()) + } + /// Borrows the executor-owned paths needed to interpret filesystem policy entries. - /// - /// Returns `None` after the cwd has been omitted from a transport context. - pub fn policy_context(&self) -> Option> { - Some(FileSystemSandboxPolicyContext { - cwd: self.cwd.as_ref()?, + pub fn policy_context(&self) -> FileSystemSandboxPolicyContext<'_> { + FileSystemSandboxPolicyContext { + cwd: &self.cwd, workspace_roots: &self.workspace_roots, user_home_dir: self.user_home_dir.as_ref(), temporary_directories: self.temporary_directories.as_deref(), - }) + } } +} - pub fn has_cwd_dependent_permissions(&self) -> bool { - match &self.permissions { +/// Filesystem RPC wire context; older clients can omit the policy cwd before executor resolution. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WireFileSystemSandboxContext { + permissions: ExecPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + policy_context: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + workspace_roots: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_home_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + temporary_directories: Option>, + #[serde(rename = "windowsSandboxLevel")] + windows_sandbox_selection: WindowsSandboxSelection, + #[serde(default)] + windows_sandbox_private_desktop: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + windows_sandbox_proxy_settings_mode: Option, + #[serde(default)] + use_legacy_landlock: bool, +} + +/// New filesystem clients provide these paths independently of the legacy helper launch cwd. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireFileSystemPolicyContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(default)] + workspace_roots: Vec, +} + +impl From for WireFileSystemSandboxContext { + fn from(sandbox: FileSystemSandboxContext) -> Self { + let FileSystemSandboxContext { + permissions, + cwd, + workspace_roots, + user_home_dir, + temporary_directories, + windows_sandbox_selection, + windows_sandbox_private_desktop, + windows_sandbox_proxy_settings_mode, + use_legacy_landlock, + } = sandbox; + let permissions = ExecPermissionProfile::from(permissions); + // Older filesystem clients sent cwd and roots only when permissions needed them; old + // executors also use that cwd to launch their helper. The explicit context is independent. + let legacy_needs_cwd = match &permissions { ExecPermissionProfile::Managed { file_system: ExecManagedFileSystemPermissions::Restricted { entries, .. }, .. } => entries.iter().any(|entry| match &entry.path { - ExecFileSystemPath::GlobPattern { pattern } => !Path::new(pattern).is_absolute(), + ExecFileSystemPath::GlobPattern { pattern } => match cwd.infer_path_convention() { + Some(convention) => LegacyAppPathString::from_string(pattern) + .to_path_uri(convention) + .is_err(), + None => true, + }, ExecFileSystemPath::Special { value: FileSystemSpecialPath::ProjectRoots { .. }, } => true, @@ -462,15 +522,73 @@ impl FileSystemSandboxContext { } | ExecPermissionProfile::Disabled | ExecPermissionProfile::External { .. } => false, + }; + Self { + permissions, + cwd: legacy_needs_cwd.then(|| cwd.clone()), + workspace_roots: if legacy_needs_cwd { + workspace_roots.clone() + } else { + Vec::new() + }, + policy_context: Some(WireFileSystemPolicyContext { + cwd: Some(cwd), + workspace_roots, + }), + user_home_dir, + temporary_directories, + windows_sandbox_selection, + windows_sandbox_private_desktop, + windows_sandbox_proxy_settings_mode, + use_legacy_landlock, } } +} - pub fn drop_cwd_if_unused(mut self) -> Self { - if !self.has_cwd_dependent_permissions() { - self.cwd = None; - self.workspace_roots.clear(); +impl WireFileSystemSandboxContext { + /// Returns the policy cwd supplied by the client, falling back to the legacy cwd field. + pub fn cwd(&self) -> Option<&PathUri> { + match &self.policy_context { + Some(policy_context) => policy_context.cwd.as_ref().or(self.cwd.as_ref()), + None => self.cwd.as_ref(), + } + } + + /// Returns whether a legacy filesystem policy needs the client's cwd to be interpreted. + pub fn requires_cwd(&self) -> bool { + let ExecPermissionProfile::Managed { + file_system: ExecManagedFileSystemPermissions::Restricted { entries, .. }, + .. + } = &self.permissions + else { + return false; + }; + + entries.iter().any(|entry| match &entry.path { + ExecFileSystemPath::GlobPattern { pattern } => !Path::new(pattern).is_absolute(), + ExecFileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { .. }, + } => true, + ExecFileSystemPath::Path { .. } | ExecFileSystemPath::Special { .. } => false, + }) + } + + /// Constructs the strict context after executor ingress has resolved the policy cwd. + pub fn into_context(self, cwd: PathUri) -> FileSystemSandboxContext { + FileSystemSandboxContext { + permissions: self.permissions.into(), + cwd, + workspace_roots: match self.policy_context { + Some(policy_context) => policy_context.workspace_roots, + None => self.workspace_roots, + }, + user_home_dir: self.user_home_dir, + temporary_directories: self.temporary_directories, + windows_sandbox_selection: self.windows_sandbox_selection, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + windows_sandbox_proxy_settings_mode: self.windows_sandbox_proxy_settings_mode, + use_legacy_landlock: self.use_legacy_landlock, } - self } } From 4b0f19d6f983c63a09ee0763e49d81ba5554d1b8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 04:37:06 +0000 Subject: [PATCH 021/373] Make TUI web and image activity summaries compact and descriptive (#46116) ## What changed - Render web activity on one line with width-aware truncation and labels for searches, opened pages, and in-page searches. Preserve all batched queries in full details. - Show viewed images by filename in chat while retaining their original paths in expanded transcripts and raw output. Use these activity renderers for persisted transcripts and exports. - Display code-mode tool titles without the `Calling` or `Called` prefix and truncate headers to the available width while preserving full transcript titles and failure details. - Keep `Returned image` markers inside the originating MCP call, alongside result text, and use the same wording in transcript exports. ## Testing Add regression tests and snapshots for narrow displays, missing web action details, batched queries, image paths, MCP image results, matching live and replay rendering, and complete web and image details in persisted exports. GitOrigin-RevId: 5d3f0a0031818c985378ad3ee4b24084d3868c98 --- ...image_activity_preserves_full_details.snap | 17 +++ codex-rs/tui/src/app/transcript_export.rs | 2 +- .../tui/src/app/transcript_export_tests.rs | 27 ++++- ...ign_image_attachment_history_snapshot.snap | 4 +- ...cal_image_attachment_history_snapshot.snap | 5 +- codex-rs/tui/src/chatwidget/tests.rs | 2 + .../src/chatwidget/tests/history_replay.rs | 4 +- ..._and_replay_render_in_the_owning_call.snap | 7 ++ .../chatwidget/tests/tool_activity_tests.rs | 34 ++++++ codex-rs/tui/src/chatwidget/tool_lifecycle.rs | 13 +- .../tui/src/history_cell/computer_activity.rs | 4 +- codex-rs/tui/src/history_cell/mcp.rs | 97 +++++++++------ codex-rs/tui/src/history_cell/mcp_result.rs | 11 +- codex-rs/tui/src/history_cell/mcp_tests.rs | 80 +++++++++++-- codex-rs/tui/src/history_cell/mod.rs | 1 - codex-rs/tui/src/history_cell/patches.rs | 57 +++++++-- .../tui/src/history_cell/patches_tests.rs | 38 ++++++ codex-rs/tui/src/history_cell/search.rs | 113 +++++++++++------- codex-rs/tui/src/history_cell/search_tests.rs | 97 +++++++++++++++ ...ty__tests__computer_activity_expanded.snap | 2 +- ...eeps_error_and_full_title_when_narrow.snap | 7 ++ ...s__tests__viewed_image_narrow_summary.snap | 5 + ..._preserves_transcript_and_raw_details.snap | 5 + ...web_action_labels_and_missing_details.snap | 10 ++ ...ol_call_accepts_data_url_image_blocks.snap | 6 + ...p_tool_call_image_after_text_snapshot.snap | 7 ++ ...l_call_multiple_image_blocks_snapshot.snap | 7 ++ ...sts__web_search_history_cell_snapshot.snap | 5 +- codex-rs/tui/src/history_cell/tests.rs | 89 +++++--------- codex-rs/tui/src/thread_transcript.rs | 22 ++-- 30 files changed, 577 insertions(+), 201 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__persisted_web_and_image_activity_preserves_full_details.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__tool_activity_tests__image_result_live_and_replay_render_in_the_owning_call.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/tool_activity_tests.rs create mode 100644 codex-rs/tui/src/history_cell/patches_tests.rs create mode 100644 codex-rs/tui/src/history_cell/search_tests.rs create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__titled_image_call_keeps_error_and_full_title_when_narrow.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__patches__tests__viewed_image_narrow_summary.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__search__tests__web_action_compact_display_preserves_transcript_and_raw_details.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__search__tests__web_action_labels_and_missing_details.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_accepts_data_url_image_blocks.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_image_after_text_snapshot.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_image_blocks_snapshot.snap diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__persisted_web_and_image_activity_preserves_full_details.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__persisted_web_and_image_activity_preserves_full_details.snap new file mode 100644 index 000000000000..b50b91c2db4f --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__persisted_web_and_image_activity_preserves_full_details.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/app/transcript_export_tests.rs +expression: "render_markdown_transcript(&cells).expect(\"exported transcript\")" +--- +# Codex conversation + +## Activity + + Opened https://example.com/long/path?query=full#details + +## Activity + + Searched for 'needle' in https://example.com/long/path + +## Activity + + Viewed image C:\workspace\assets\screenshot.png diff --git a/codex-rs/tui/src/app/transcript_export.rs b/codex-rs/tui/src/app/transcript_export.rs index 67365721cc18..06528e2036c7 100644 --- a/codex-rs/tui/src/app/transcript_export.rs +++ b/codex-rs/tui/src/app/transcript_export.rs @@ -162,7 +162,7 @@ fn export_activity_cell(item: &ThreadItem) -> Option { lines.extend(raw_lines_from_source(&text.text)); } Ok(rmcp::model::ContentBlock::Image(_)) => { - lines.push("".into()); + lines.push("Returned image".into()); } Ok(rmcp::model::ContentBlock::Audio(_)) => { lines.push("