diff --git a/.codex/skills/babysit-pr/SKILL.md b/.codex/skills/babysit-pr/SKILL.md index 1b95144297..68fc7278ba 100644 --- a/.codex/skills/babysit-pr/SKILL.md +++ b/.codex/skills/babysit-pr/SKILL.md @@ -29,7 +29,7 @@ Accept any of the following: 4. If `diagnose_ci_failure` is present, inspect failed run logs and classify the failure. 5. If the failure is likely caused by the current branch, patch code locally, commit, and push. Do not patch random flaky tests, CI infrastructure, dependency outages, runner issues, or other failures that are unrelated to the branch. 6. If `process_review_comment` is present, inspect surfaced review items and decide whether to address them. -7. If a review item is actionable and correct, patch code locally, commit, push, and then mark the associated review thread/comment as resolved once the fix is on GitHub. +7. If a review item is actionable and correct, patch code locally, commit, push, and then resolve the associated review thread only when allowed by the GitHub state mutation policy below. 8. Do not post replies to human-authored review comments/threads unless the user explicitly confirms the exact response. If a human review item is non-actionable, already addressed, or not valid, surface the item and recommended response to the user instead of replying on GitHub. 9. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`. 10. If both actionable review feedback and `retry_failed_checks` are present, prioritize review feedback first; a new commit will retrigger CI, so avoid rerunning flaky checks on the old SHA unless you intentionally defer the review change. @@ -101,7 +101,7 @@ When you agree with a comment and it is actionable: 1. Patch code locally. 2. Commit with `codex: address PR review feedback (#)`. 3. Push to the PR head branch. -4. After the push succeeds, mark the associated GitHub review thread/comment as resolved. +4. After the push succeeds, resolve the associated GitHub review thread only when allowed by the GitHub state mutation policy below. 5. Resume watching on the new SHA immediately (do not stop after reporting the push). 6. If monitoring was running in `--watch` mode, restart `--watch` immediately after the push in the same turn; do not wait for the user to ask again. @@ -109,6 +109,31 @@ Do not post replies to human-authored GitHub review comments/threads automatical If the watcher later surfaces your own approved reply because the authenticated operator is treated as a trusted review author, treat that self-authored item as already handled and do not reply again. If a code review comment/thread is already marked as resolved in GitHub, treat it as non-actionable and safely ignore it unless new unresolved follow-up feedback appears. +## GitHub State Mutation Policy + +You can read any PR state you need for monitoring. Writes must comply with this policy. + +You can push PRs to update the code under review or to force CI re-runs as described above. + +You can resolve review comment threads from the human who requested babysitting or from the Codex +review bot. When resolving, leave a comment prefixed with `[from Codex]: ` and explain what changes +you made and which commit includes them. Don't touch review threads if other humans other than the +user who requested babysitting have participated. + +Before making any changes, fetch the PR state yourself instead of relying on the PR watcher script's +output. + +Unless explicitly asked, do not: + +* comment on other humans' review threads, communicate with the user in chat instead +* resolve review threads from humans other than the user +* interact with humans other than the user +* mark PRs as drafts or ready for review +* close or reopen PRs + +In general, never act on GitHub in ways that would make it hard to tell whether you or the user did +something visible to other humans. When in doubt, ask the user for clarification in chat. + ## Git Safety Rules - Work only on the PR head branch. @@ -133,10 +158,10 @@ Use this loop in a live Codex session: 3. First check whether the PR is now merged or otherwise closed; if so, report that terminal state and stop polling immediately. 4. Check CI summary, new review items, and mergeability/conflict status. 5. Diagnose CI failures and classify branch-related vs flaky/unrelated. If the overall run is still pending but `failed_jobs` already includes a failed job, fetch that job's logs and diagnose immediately instead of waiting for the whole workflow run to finish. Patch only when the failure is branch-related. -6. For each surfaced review item from another author, patch/commit/push and then resolve it if it is actionable. If it is non-actionable, already addressed, or requires a written answer, surface it to the user with a suggested response instead of posting automatically. If a later snapshot surfaces your own approved reply, treat it as informational and continue without responding again. +6. For each surfaced review item from another author, patch/commit/push if it is actionable, then resolve it only when allowed by the GitHub state mutation policy above. If it is non-actionable, already addressed, or requires a written answer, surface it to the user with a suggested response instead of posting automatically. If a later snapshot surfaces your own approved reply, treat it as informational and continue without responding again. 7. Process actionable review comments before flaky reruns when both are present; if a review fix requires a commit, push it and skip rerunning failed checks on the old SHA. 8. Retry failed checks only when `retry_failed_checks` is present and you are not about to replace the current SHA with a review/CI fix commit. Do not make code changes for unrelated flakes or infrastructure failures just to get CI green. -9. If you pushed a commit, resolved a review thread, or triggered a rerun, report the action briefly and continue polling (do not stop). If a human review comment needs a written GitHub response, stop and ask for confirmation before posting. +9. If you pushed a commit, resolved an eligible review thread, or triggered a rerun, report the action briefly and continue polling (do not stop). If a human review comment needs a written GitHub response, stop and ask for confirmation before posting. 10. After a review-fix push, proactively restart continuous monitoring (`--watch`) in the same turn unless a strict stop condition has already been reached. 11. If everything is passing, mergeable, not blocked on required review approval, and there are no unaddressed review items, report that the PR is currently ready to merge but keep the watcher running so new review comments are surfaced quickly while the PR remains open. 12. If blocked on a user-help-required issue (infra outage, exhausted flaky retries, unclear reviewer request, permissions), report the blocker and stop. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d29c06e6f0..48674d3968 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,6 +2,7 @@ /codex-rs/core/ @openai/codex-core-agent-team /codex-rs/ext/extension-api/ @openai/codex-core-agent-team /codex-rs/prompts/ @openai/codex-core-agent-team +/codex-rs/utils/path-uri/ @openai/codex-core-agent-team # Keep macOS AKV signing changes reviewed by Codex maintainers. /.github/actions/setup-akv-pkcs11-codesigning/ @openai/codex-core-agent-team diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ec5bfebf7b..3d8086c27b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3106,6 +3106,7 @@ dependencies = [ "codex-protocol", "codex-tools", "codex-utils-absolute-path", + "codex-utils-image", "http 1.4.0", "pretty_assertions", "schemars 0.8.22", @@ -4151,6 +4152,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-utils-path-uri" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.18", + "ts-rs", + "url", + "urlencoding", +] + [[package]] name = "codex-utils-plugins" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3bb23ba80a..c72b7c7844 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -83,6 +83,7 @@ members = [ "tools", "v8-poc", "utils/absolute-path", + "utils/path-uri", "utils/cargo-bin", "git-utils", "utils/cache", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a3b6dfa949..7172de6898 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -108,6 +108,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_app_server_protocol::SessionSource as AppServerSessionSource; +use codex_app_server_protocol::SubAgentActivityKind; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; @@ -3318,7 +3319,6 @@ fn turn_event_serializes_expected_shape() { status: Some(TurnStatus::Completed), turn_error: None, codex_error_kind: None, - codex_error_subreason: None, codex_error_http_status_code: None, steer_count: Some(0), total_tool_call_count: None, @@ -3391,7 +3391,6 @@ fn turn_event_serializes_expected_shape() { "status": "completed", "turn_error": null, "codex_error_kind": null, - "codex_error_subreason": null, "codex_error_http_status_code": null, "steer_count": 0, "total_tool_call_count": null, @@ -3796,6 +3795,12 @@ async fn turn_event_counts_completed_tool_items() { reasoning_effort: None, agents_states: Default::default(), }, + ThreadItem::SubAgentActivity { + id: "sub-agent-activity-1".to_string(), + kind: SubAgentActivityKind::Interacted, + agent_thread_id: "thread-child".to_string(), + agent_path: "/root/child".to_string(), + }, ThreadItem::WebSearch { id: "web-1".to_string(), query: "codex".to_string(), @@ -3843,14 +3848,14 @@ async fn turn_event_counts_completed_tool_items() { .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) .expect("turn event should be emitted"); let payload = serde_json::to_value(turn_event).expect("serialize turn event"); - assert_eq!(payload["event_params"]["total_tool_call_count"], json!(7)); + assert_eq!(payload["event_params"]["total_tool_call_count"], json!(8)); assert_eq!(payload["event_params"]["shell_command_count"], json!(1)); assert_eq!(payload["event_params"]["file_change_count"], json!(1)); assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(1)); assert_eq!(payload["event_params"]["dynamic_tool_call_count"], json!(1)); assert_eq!( payload["event_params"]["subagent_tool_call_count"], - json!(1) + json!(2) ); assert_eq!(payload["event_params"]["web_search_count"], json!(1)); assert_eq!(payload["event_params"]["image_generation_count"], json!(1)); @@ -4098,10 +4103,6 @@ async fn turn_lifecycle_emits_failed_turn_event() { payload["event_params"]["codex_error_kind"], json!("invalid_request") ); - assert_eq!( - payload["event_params"]["codex_error_subreason"], - json!("unknown turn environment id `env-2`") - ); assert_eq!( payload["event_params"]["codex_error_http_status_code"], json!(null) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 019d59776a..0b6c9a0122 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -803,7 +803,6 @@ pub(crate) struct CodexTurnEventParams { pub(crate) status: Option, pub(crate) turn_error: Option, pub(crate) codex_error_kind: Option, - pub(crate) codex_error_subreason: Option, pub(crate) codex_error_http_status_code: Option, pub(crate) steer_count: Option, pub(crate) total_tool_call_count: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index c9d245ce90..a035bc98b9 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -30,9 +30,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; use std::path::PathBuf; -const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512; -const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "..."; - #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AcceptedLineFingerprint { pub path_hash: String, @@ -182,7 +179,6 @@ pub(crate) enum CodexErrKind { #[derive(Clone)] pub(crate) struct TurnCodexError { pub(crate) kind: CodexErrKind, - pub(crate) subreason: Option, pub(crate) http_status_code: Option, } @@ -190,26 +186,6 @@ impl TurnCodexError { fn from_codex_err(error: &CodexErr) -> Self { Self { kind: error.into(), - subreason: match error { - CodexErr::InvalidRequest(message) => { - // InvalidRequest can contain raw provider response bodies, so bound the - // analytics copy without changing the source CodexErr. - let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES { - message.clone() - } else { - let truncated_len = message.floor_char_boundary( - INVALID_REQUEST_SUBREASON_MAX_BYTES - .saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()), - ); - format!( - "{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}", - &message[..truncated_len] - ) - }; - Some(subreason) - } - _ => None, - }, http_status_code: error.http_status_code_value(), } } diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 66caaafe01..0cceb2b46d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -360,7 +360,9 @@ impl TurnToolCounts { ThreadItem::FileChange { .. } => self.file_change += 1, ThreadItem::McpToolCall { .. } => self.mcp_tool_call += 1, ThreadItem::DynamicToolCall { .. } => self.dynamic_tool_call += 1, - ThreadItem::CollabAgentToolCall { .. } => self.subagent_tool_call += 1, + ThreadItem::CollabAgentToolCall { .. } | ThreadItem::SubAgentActivity { .. } => { + self.subagent_tool_call += 1; + } ThreadItem::WebSearch { .. } => self.web_search += 1, ThreadItem::ImageGeneration { .. } => self.image_generation += 1, ThreadItem::UserMessage { .. } @@ -1091,6 +1093,18 @@ impl AnalyticsReducer { ); } ServerNotification::ItemCompleted(notification) => { + if matches!(notification.item, ThreadItem::SubAgentActivity { .. }) { + let Some(turn_state) = self.turns.get_mut(¬ification.turn_id) else { + tracing::warn!( + thread_id = %notification.thread_id, + turn_id = %notification.turn_id, + "dropping sub-agent activity tool count update: missing turn state" + ); + return; + }; + turn_state.tool_counts.record(¬ification.item); + return; + } let Some(item_id) = tracked_tool_item_id(¬ification.item) else { return; }; @@ -1562,6 +1576,7 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { | ThreadItem::AgentMessage { .. } | ThreadItem::Plan { .. } | ThreadItem::Reasoning { .. } + | ThreadItem::SubAgentActivity { .. } | ThreadItem::ImageView { .. } | ThreadItem::EnteredReviewMode { .. } | ThreadItem::ExitedReviewMode { .. } @@ -2470,7 +2485,6 @@ fn codex_turn_event_params( status: completed.status, turn_error: completed.turn_error, codex_error_kind: codex_error.map(|error| error.kind), - codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()), codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code), steer_count: Some(turn_state.steer_count), total_tool_call_count: Some(turn_state.tool_counts.total), diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index dac3a4fdff..01f6f58b09 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3177,6 +3177,14 @@ ], "type": "object" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -4107,6 +4115,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1873ed317e..7abc5af7d0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15580,6 +15580,14 @@ ], "type": "object" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -16892,6 +16900,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/v2/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 38c74b1859..e5ac77c088 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13397,6 +13397,14 @@ ], "type": "object" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -14709,6 +14717,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 4b1d1dddbf..162f3aa3d9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -466,6 +466,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -989,6 +997,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 67e493a4da..af3b1ddd1b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -466,6 +466,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -989,6 +997,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 0e3e704bf2..8918f6cae4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -610,6 +610,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -1133,6 +1141,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 0dbb7ef004..0cea90749b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -861,6 +861,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1617,6 +1625,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 903553afaa..d0b3351a4e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -676,6 +676,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1432,6 +1440,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 93f44759bf..e2850b3dd9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -676,6 +676,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1432,6 +1440,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index a78d1837c8..01da3165b1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -676,6 +676,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1432,6 +1440,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 288c3e6c8e..d766d7a024 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -861,6 +861,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1617,6 +1625,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 2ef7959a8b..6448ca6517 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -676,6 +676,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1432,6 +1440,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 1a182e5d69..a19a40f522 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -861,6 +861,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1617,6 +1625,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 66ed134c83..0eb79075f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -676,6 +676,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1432,6 +1440,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index d331fa9bf7..71064d5ea2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -676,6 +676,14 @@ } ] }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "SubAgentSource": { "oneOf": [ { @@ -1432,6 +1440,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index dd5ebbf6ee..3cc329c9e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -610,6 +610,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -1133,6 +1141,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index cc4bce10ff..7dbea8af53 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -610,6 +610,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -1133,6 +1141,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 10b43d4354..362e789ea8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -610,6 +610,14 @@ "minLength": 1, "type": "string" }, + "SubAgentActivityKind": { + "enum": [ + "started", + "interacted", + "interrupted" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -1133,6 +1141,38 @@ "title": "CollabAgentToolCallThreadItem", "type": "object" }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, { "properties": { "action": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SubAgentActivityKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SubAgentActivityKind.ts new file mode 100644 index 0000000000..5e3ce81e01 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SubAgentActivityKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubAgentActivityKind = "started" | "interacted" | "interrupted"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 78dff0d765..8d74ae8de2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -20,6 +20,7 @@ import type { McpToolCallResult } from "./McpToolCallResult"; import type { McpToolCallStatus } from "./McpToolCallStatus"; import type { MemoryCitation } from "./MemoryCitation"; import type { PatchApplyStatus } from "./PatchApplyStatus"; +import type { SubAgentActivityKind } from "./SubAgentActivityKind"; import type { UserInput } from "./UserInput"; import type { WebSearchAction } from "./WebSearchAction"; @@ -98,4 +99,4 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 727c049d10..eab3494e1f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -356,6 +356,7 @@ export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; export type { SortDirection } from "./SortDirection"; export type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; +export type { SubAgentActivityKind } from "./SubAgentActivityKind"; export type { SubagentMigration } from "./SubagentMigration"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs index 609ca83a5d..6d6012e19b 100644 --- a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -178,6 +178,20 @@ pub fn item_event_to_server_notification( completed_at_ms: end_event.completed_at_ms, }) } + EventMsg::SubAgentActivity(activity) => { + let item = ThreadItem::SubAgentActivity { + id: activity.event_id, + kind: activity.kind.into(), + agent_thread_id: activity.agent_thread_id.to_string(), + agent_path: String::from(activity.agent_path), + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + completed_at_ms: activity.occurred_at_ms, + }) + } EventMsg::CollabWaitingBegin(begin_event) => { let receiver_thread_ids = begin_event .receiver_thread_ids diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index fdc2af15d3..075b3e043e 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -203,6 +203,7 @@ impl ThreadHistoryBuilder { EventMsg::CollabAgentInteractionEnd(payload) => { self.handle_collab_agent_interaction_end(payload) } + EventMsg::SubAgentActivity(payload) => self.handle_sub_agent_activity(payload), EventMsg::CollabWaitingBegin(payload) => self.handle_collab_waiting_begin(payload), EventMsg::CollabWaitingEnd(payload) => self.handle_collab_waiting_end(payload), EventMsg::CollabCloseBegin(payload) => self.handle_collab_close_begin(payload), @@ -702,6 +703,18 @@ impl ThreadHistoryBuilder { }); } + fn handle_sub_agent_activity( + &mut self, + payload: &codex_protocol::protocol::SubAgentActivityEvent, + ) { + self.upsert_item_in_current_turn(ThreadItem::SubAgentActivity { + id: payload.event_id.clone(), + kind: payload.kind.into(), + agent_thread_id: payload.agent_thread_id.to_string(), + agent_path: String::from(payload.agent_path.clone()), + }); + } + fn handle_collab_waiting_begin( &mut self, payload: &codex_protocol::protocol::CollabWaitingBeginEvent, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index d68485565e..29d212ab15 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -29,6 +29,7 @@ use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; +use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -336,6 +337,14 @@ pub enum ThreadItem { }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] + SubAgentActivity { + id: String, + kind: SubAgentActivityKind, + agent_thread_id: String, + agent_path: String, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] WebSearch { id: String, query: String, @@ -387,6 +396,7 @@ impl ThreadItem { | ThreadItem::McpToolCall { id, .. } | ThreadItem::DynamicToolCall { id, .. } | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::SubAgentActivity { id, .. } | ThreadItem::WebSearch { id, .. } | ThreadItem::ImageView { id, .. } | ThreadItem::ImageGeneration { id, .. } @@ -1004,6 +1014,25 @@ pub enum CollabAgentToolCallStatus { Failed, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum SubAgentActivityKind { + Started, + Interacted, + Interrupted, +} + +impl From for SubAgentActivityKind { + fn from(value: CoreSubAgentActivityKind) -> Self { + match value { + CoreSubAgentActivityKind::Started => SubAgentActivityKind::Started, + CoreSubAgentActivityKind::Interacted => SubAgentActivityKind::Interacted, + CoreSubAgentActivityKind::Interrupted => SubAgentActivityKind::Interrupted, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 3bac7bf7ec..84567fb4c2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -76,7 +76,7 @@ pub struct TurnStartParams { #[experimental("turn/start.additionalContext")] #[ts(optional = nullable)] pub additional_context: Option>, - /// Optional turn-scoped environments. + /// Optional environments for this turn and subsequent turns. /// /// Omitted uses the thread sticky environments. Empty disables /// environment access for this turn. Non-empty selects the first diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 547d0666ed..1f47bc4dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -100,6 +100,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::protocol::SubAgentActivityKind; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; @@ -767,7 +768,7 @@ pub(crate) async fn apply_bespoke_event_handling( let requested_permissions = request.permissions.clone(); let request_cwd = match request.cwd.clone() { Some(cwd) => cwd, - None => conversation.config_snapshot().await.cwd, + None => conversation.config_snapshot().await.cwd().clone(), }; let params = PermissionsRequestApprovalParams { thread_id: conversation_id.to_string(), @@ -861,6 +862,24 @@ pub(crate) async fn apply_bespoke_event_handling( ); outgoing.send_server_notification(notification).await; } + EventMsg::SubAgentActivity(activity) => { + if activity.kind == SubAgentActivityKind::Interrupted + && thread_manager + .get_thread(activity.agent_thread_id) + .await + .is_err() + { + thread_watch_manager + .remove_thread(&activity.agent_thread_id.to_string()) + .await; + } + let notification = item_event_to_server_notification( + EventMsg::SubAgentActivity(activity), + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } EventMsg::CollabCloseEnd(end_event) => { if thread_manager .get_thread(end_event.receiver_thread_id) @@ -1161,7 +1180,7 @@ pub(crate) async fn apply_bespoke_event_handling( return; } }; - let fallback_cwd = conversation.config_snapshot().await.cwd; + let fallback_cwd = conversation.config_snapshot().await.cwd().clone(); let stored_thread = match conversation .read_thread( /*include_archived*/ true, /*include_history*/ true, @@ -2104,6 +2123,7 @@ mod tests { use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; use codex_login::CodexAuth; + use codex_protocol::AgentPath; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; @@ -2125,6 +2145,7 @@ mod tests { use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::UserMessageEvent; @@ -2181,6 +2202,7 @@ mod tests { ]; let stored_thread = StoredThread { thread_id, + extra_config: None, rollout_path: None, forked_from_id: None, parent_thread_id: None, @@ -3298,6 +3320,94 @@ mod tests { Ok(()) } + #[tokio::test] + async fn interrupted_subagent_activity_removes_missing_thread_watch() -> Result<()> { + let codex_home = TempDir::new()?; + let config = load_default_config_for_test(&codex_home).await; + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ), + ); + let codex_core::NewThread { + thread_id: conversation_id, + thread: conversation, + .. + } = thread_manager.start_thread(config).await?; + let child_thread_id = ThreadId::new(); + let child_thread_id_string = child_thread_id.to_string(); + let thread_watch_manager = ThreadWatchManager::new(); + thread_watch_manager + .note_turn_started(&child_thread_id_string) + .await; + assert_eq!(thread_watch_manager.running_turn_count().await, 1); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + + apply_bespoke_event_handling( + Event { + id: "turn-1".to_string(), + msg: EventMsg::SubAgentActivity(SubAgentActivityEvent { + event_id: "activity-1".to_string(), + occurred_at_ms: 42, + agent_thread_id: child_thread_id, + agent_path: AgentPath::try_from("/root/worker") + .expect("agent path should parse"), + kind: SubAgentActivityKind::Interrupted, + }), + }, + conversation_id, + conversation, + thread_manager, + outgoing, + new_thread_state(), + thread_watch_manager.clone(), + Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), + "test-provider".to_string(), + ) + .await; + + assert_eq!( + thread_watch_manager + .loaded_status_for_thread(&child_thread_id_string) + .await, + ThreadStatus::NotLoaded + ); + assert_eq!(thread_watch_manager.running_turn_count().await, 0); + let message = recv_broadcast_message(&mut rx).await?; + let OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) = + message + else { + bail!("unexpected message: {message:?}"); + }; + assert_eq!( + payload, + ItemCompletedNotification { + item: ThreadItem::SubAgentActivity { + id: "activity-1".to_string(), + kind: codex_app_server_protocol::SubAgentActivityKind::Interrupted, + agent_thread_id: child_thread_id_string, + agent_path: "/root/worker".to_string(), + }, + thread_id: conversation_id.to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 42, + } + ); + Ok(()) + } + #[tokio::test] async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> { let conversation_id = ThreadId::new(); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 79da49fba4..d4e9395627 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -330,6 +330,7 @@ use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginS use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; use codex_features::Feature; @@ -400,6 +401,7 @@ use codex_protocol::protocol::SessionConfiguredEvent; #[cfg(test)] use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 49a6615f1c..4a665d8ef3 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -53,7 +53,7 @@ impl AppsRequestProcessor { None }; let fallback_cwd = match thread.as_ref() { - Some(thread) => Some(thread.config_snapshot().await.cwd.to_path_buf()), + Some(thread) => Some(thread.config_snapshot().await.cwd().to_path_buf()), None => None, }; let mut config = self.load_latest_config(fallback_cwd).await?; diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index 163210f164..4cec9bca16 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -177,6 +177,7 @@ impl ExternalAgentSessionImporter { let now = Utc::now(); let create_params = CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: source.clone(), diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 79791c142a..bc2383c21f 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -7,9 +7,13 @@ use codex_app_server_protocol::PluginSharePrincipalRole; use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::PluginListBackgroundTaskOptions; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; -use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; use codex_mcp::McpOAuthLoginSupport; @@ -148,15 +152,18 @@ fn convert_configured_marketplace_plugin_to_plugin_summary( } } -fn remote_installed_plugin_visible_scopes(config: &Config) -> Vec { - let mut scopes = Vec::new(); +fn remote_installed_plugin_visible_marketplaces(config: &Config) -> Vec<&'static str> { + let mut marketplaces = Vec::new(); if config.features.enabled(Feature::RemotePlugin) { - scopes.push(RemotePluginScope::Global); + marketplaces.push(REMOTE_GLOBAL_MARKETPLACE_NAME); } + marketplaces.push(REMOTE_WORKSPACE_MARKETPLACE_NAME); if config.features.enabled(Feature::PluginSharing) { - scopes.push(RemotePluginScope::Workspace); + marketplaces.push(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME); + marketplaces.push(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME); + marketplaces.push(REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME); } - scopes + marketplaces } fn filter_openai_curated_installed_conflicts( @@ -543,21 +550,30 @@ impl PluginRequestProcessor { return Ok(empty_response()); } let plugins_input = config.plugins_config_input(); - if include_local || marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) { - plugins_manager.maybe_start_plugin_list_background_tasks_for_config( - &plugins_input, - auth.clone(), - &roots, - Some(self.effective_plugins_changed_callback()), + let include_shared_with_me = + marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe); + let include_global_remote = + !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin); + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let refresh_global_remote_catalog_cache = include_global_remote + && codex_core_plugins::remote::has_cached_global_remote_plugin_catalog( + config.codex_home.as_path(), + &remote_plugin_service_config, + auth.as_ref(), ); - } let (mut data, marketplace_load_errors) = if include_local { let config_for_marketplace_listing = plugins_input.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); + let roots_for_marketplace_listing = roots.clone(); let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?; match tokio::task::spawn_blocking(move || { let outcome = plugins_manager_for_marketplace_listing - .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + .list_marketplaces_for_config( + &config_for_marketplace_listing, + &roots_for_marketplace_listing, + )?; Ok::< ( Vec, @@ -617,9 +633,6 @@ impl PluginRequestProcessor { // TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are // served directly from the normal remote catalog. if include_vertical && !config.features.enabled(Feature::RemotePlugin) { - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; match codex_core_plugins::remote::fetch_openai_curated_remote_collection_marketplace( &remote_plugin_service_config, auth.as_ref(), @@ -644,21 +657,16 @@ impl PluginRequestProcessor { } let mut remote_sources = Vec::new(); - if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) { + if include_global_remote { remote_sources.push(RemoteMarketplaceSource::Global); } if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); } - if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) - && config.features.enabled(Feature::PluginSharing) - { + if include_shared_with_me && config.features.enabled(Feature::PluginSharing) { remote_sources.push(RemoteMarketplaceSource::SharedWithMe); } if !remote_sources.is_empty() { - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; match codex_core_plugins::remote::fetch_remote_marketplaces( &remote_plugin_service_config, auth.as_ref(), @@ -702,6 +710,17 @@ impl PluginRequestProcessor { } } } + if include_local || include_shared_with_me || include_global_remote { + plugins_manager.maybe_start_plugin_list_background_tasks_for_config( + &plugins_input, + auth.clone(), + &roots, + PluginListBackgroundTaskOptions { + refresh_global_remote_catalog_cache, + }, + Some(self.effective_plugins_changed_callback()), + ); + } let featured_plugin_ids = if data .iter() @@ -763,8 +782,8 @@ impl PluginRequestProcessor { } let plugins_input = config.plugins_config_input(); - let remote_installed_plugin_visible_scopes = - remote_installed_plugin_visible_scopes(&config); + let remote_installed_plugin_visible_marketplaces = + remote_installed_plugin_visible_marketplaces(&config); plugins_manager.maybe_start_remote_installed_plugin_bundle_sync( &plugins_input, auth.clone(), @@ -785,7 +804,7 @@ impl PluginRequestProcessor { self.load_remote_installed_plugins( plugins_manager, &plugins_input, - &remote_installed_plugin_visible_scopes, + &remote_installed_plugin_visible_marketplaces, auth.as_ref(), ) .await, @@ -885,11 +904,11 @@ impl PluginRequestProcessor { &self, plugins_manager: Arc, plugins_input: &codex_core_plugins::PluginsConfigInput, - visible_scopes: &[RemotePluginScope], + visible_marketplaces: &[&str], auth: Option<&CodexAuth>, ) -> Vec { - let remote_marketplaces = if let Some(remote_marketplaces) = - plugins_manager.build_remote_installed_plugin_marketplaces_from_cache(visible_scopes) + let remote_marketplaces = if let Some(remote_marketplaces) = plugins_manager + .build_remote_installed_plugin_marketplaces_from_cache(visible_marketplaces) { Ok(remote_marketplaces) } else { @@ -897,7 +916,7 @@ impl PluginRequestProcessor { .build_and_cache_remote_installed_plugin_marketplaces( plugins_input, auth, - visible_scopes, + visible_marketplaces, Some(self.effective_plugins_changed_callback()), ) .await @@ -1506,7 +1525,7 @@ impl PluginRequestProcessor { // Cache first so a backend install cannot succeed when local materialization fails. // If this backend call fails, the cache entry is harmless because remote installed state // is still backend-gated. - codex_core_plugins::remote::install_remote_plugin( + let install_result = codex_core_plugins::remote::install_remote_plugin( &remote_plugin_service_config, auth.as_ref(), &actual_remote_marketplace_name, @@ -1525,7 +1544,7 @@ impl PluginRequestProcessor { let mut plugin_metadata = plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; - plugin_metadata.remote_plugin_id = Some(remote_plugin_id); + plugin_metadata.remote_plugin_id = Some(remote_plugin_id.clone()); self.analytics_events_client .track_plugin_installed(plugin_metadata); @@ -1535,15 +1554,42 @@ impl PluginRequestProcessor { .await; } - let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; - let apps_needing_auth = self - .plugin_apps_needing_auth_for_install( - &config, - auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - &result.plugin_id.as_key(), - &plugin_apps, - ) - .await; + let is_chatgpt_auth = auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth); + let apps_needing_auth = + if let Some(app_ids_needing_auth) = install_result.app_ids_needing_auth { + if app_ids_needing_auth.is_empty() + || !config.features.apps_enabled_for_auth(is_chatgpt_auth) + { + Vec::new() + } else { + let plugin_apps = app_ids_needing_auth + .into_iter() + .map(codex_plugin::AppConnectorId) + .collect::>(); + let all_connectors = connectors::list_cached_all_connectors(&config) + .await + .unwrap_or_default(); + connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps) + .into_iter() + .map(|connector| AppSummary { + id: connector.id, + name: connector.name, + description: connector.description, + install_url: connector.install_url, + needs_auth: true, + }) + .collect() + } + } else { + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + self.plugin_apps_needing_auth_for_install( + &config, + is_chatgpt_auth, + &result.plugin_id.as_key(), + &plugin_apps, + ) + .await + }; Ok(PluginInstallResponse { auth_policy: remote_detail.summary.auth_policy, diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 8bfb282ace..fd9e93e189 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -627,6 +627,8 @@ pub(super) async fn handle_pending_thread_resume_request( } } + let config_snapshot = pending.config_snapshot; + let cwd = config_snapshot.cwd().clone(); let ThreadConfigSnapshot { model, model_provider_id, @@ -635,11 +637,10 @@ pub(super) async fn handle_pending_thread_resume_request( approvals_reviewer, permission_profile, active_permission_profile, - cwd, workspace_roots, reasoning_effort, .. - } = pending.config_snapshot; + } = config_snapshot; let instruction_sources = pending.instruction_sources; let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let active_permission_profile = diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index eab606bf62..4cc169f80f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -47,11 +47,11 @@ fn collect_resume_override_mismatches( } if let Some(requested_cwd) = request.cwd.as_deref() { let requested_cwd_path = std::path::PathBuf::from(requested_cwd); - if requested_cwd_path != config_snapshot.cwd.as_path() { + if requested_cwd_path != config_snapshot.cwd().as_path() { mismatch_details.push(format!( "cwd requested={} active={}", requested_cwd_path.display(), - config_snapshot.cwd.display() + config_snapshot.cwd().display() )); } } @@ -1154,8 +1154,9 @@ impl ThreadRequestProcessor { let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ); + let cwd = config_snapshot.cwd().clone(); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -1164,7 +1165,7 @@ impl ThreadRequestProcessor { model: config_snapshot.model, model_provider: config_snapshot.model_provider_id, service_tier: config_snapshot.service_tier, - cwd: config_snapshot.cwd, + cwd, runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: config_snapshot.approval_policy.into(), @@ -2601,7 +2602,7 @@ impl ThreadRequestProcessor { let config_snapshot = codex_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ); let active_permission_profile = thread_response_active_permission_profile( config_snapshot.active_permission_profile, @@ -3335,7 +3336,7 @@ impl ThreadRequestProcessor { let config_snapshot = forked_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -4205,7 +4206,7 @@ fn build_thread_from_snapshot( updated_at: now, status: ThreadStatus::NotLoaded, path, - cwd: config_snapshot.cwd.clone(), + cwd: config_snapshot.cwd().clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: config_snapshot.session_source.get_nickname(), agent_role: config_snapshot.session_source.get_agent_role(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index c0a909e577..54c0f0343f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -77,6 +77,7 @@ mod thread_processor_behavior_tests { use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; + use codex_protocol::protocol::TurnEnvironmentSelections; use codex_state::ThreadMetadataBuilder; use codex_thread_store::StoredThread; use codex_utils_absolute_path::test_support::PathBufExt; @@ -393,6 +394,7 @@ mod thread_processor_behavior_tests { ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread"); let stored_thread = StoredThread { thread_id, + extra_config: None, rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), forked_from_id: None, parent_thread_id: None, @@ -685,7 +687,7 @@ mod thread_processor_behavior_tests { approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, - cwd, + environments: TurnEnvironmentSelections::new(cwd, Vec::new()), workspace_roots: Vec::new(), profile_workspace_roots: Vec::new(), ephemeral: false, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index f467074828..0fb1320055 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -190,12 +190,12 @@ pub(crate) fn thread_settings_from_config_snapshot( config_snapshot: &ThreadConfigSnapshot, ) -> ThreadSettings { ThreadSettings { - cwd: config_snapshot.cwd.clone(), + cwd: config_snapshot.cwd().clone(), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox_policy: thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ), active_permission_profile: thread_response_active_permission_profile( config_snapshot.active_permission_profile.clone(), @@ -213,24 +213,36 @@ pub(crate) fn thread_settings_from_config_snapshot( pub(crate) fn thread_settings_from_core_snapshot( snapshot: codex_protocol::protocol::ThreadSettingsSnapshot, ) -> ThreadSettings { + let codex_protocol::protocol::ThreadSettingsSnapshot { + model, + model_provider_id, + service_tier, + approval_policy, + approvals_reviewer, + permission_profile, + active_permission_profile, + cwd, + reasoning_effort, + reasoning_summary, + personality, + collaboration_mode, + } = snapshot; + let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); ThreadSettings { - sandbox_policy: thread_response_sandbox_policy( - &snapshot.permission_profile, - snapshot.cwd.as_path(), - ), - cwd: snapshot.cwd, - approval_policy: snapshot.approval_policy.into(), - approvals_reviewer: snapshot.approvals_reviewer.into(), + sandbox_policy, + cwd, + approval_policy: approval_policy.into(), + approvals_reviewer: approvals_reviewer.into(), active_permission_profile: thread_response_active_permission_profile( - snapshot.active_permission_profile, + active_permission_profile, ), - model: snapshot.model, - model_provider: snapshot.model_provider_id, - service_tier: snapshot.service_tier, - effort: snapshot.reasoning_effort, - summary: snapshot.reasoning_summary, - collaboration_mode: snapshot.collaboration_mode, - personality: snapshot.personality, + model, + model_provider: model_provider_id, + service_tier, + effort: reasoning_effort, + summary: reasoning_summary, + collaboration_mode, + personality, } } diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index ce98a2e8e0..7bdd9df0d8 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -43,7 +43,7 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, - cwd: Option, + environments: Option, runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, @@ -406,12 +406,14 @@ impl TurnRequestProcessor { let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); let cwd = resolve_request_cwd(params.cwd)?; + let environments = + Self::build_environment_override(thread.as_ref(), cwd, environment_selections).await; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "turn/start", - cwd, + environments, runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, @@ -430,7 +432,6 @@ impl TurnRequestProcessor { // Start the turn by submitting the user input. Return its submission id as turn_id. let turn_op = Op::UserInput { items: mapped_items, - environments: environment_selections, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, additional_context, @@ -478,6 +479,31 @@ impl TurnRequestProcessor { Ok(TurnStartResponse { turn }) } + async fn build_environment_override( + thread: &CodexThread, + cwd: Option, + environment_selections: Option>, + ) -> Option { + if cwd.is_none() && environment_selections.is_none() { + return None; + } + + let snapshot = thread.config_snapshot().await; + let environment_selections = + environment_selections.unwrap_or_else(|| snapshot.environment_selections().to_vec()); + let legacy_fallback_cwd = cwd.unwrap_or_else(|| { + environment_selections + .iter() + .find(|selection| selection.environment_id == LOCAL_ENVIRONMENT_ID) + .map(|selection| selection.cwd.clone()) + .unwrap_or_else(|| snapshot.cwd().clone()) + }); + Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )) + } + async fn build_thread_settings_overrides( &self, thread: &CodexThread, @@ -485,7 +511,7 @@ impl TurnRequestProcessor { ) -> Result { let ThreadSettingsBuildParams { method, - cwd, + environments, runtime_workspace_roots, approval_policy, approvals_reviewer, @@ -508,6 +534,7 @@ impl TurnRequestProcessor { let collaboration_mode = collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); let runtime_workspace_roots_request = runtime_workspace_roots; + let has_environment_override = environments.is_some(); // `thread/settings/update` only acknowledges that the update was queued. // Clients that send dependent partial updates should wait for // `thread/settings/updated` or combine the fields in one request. @@ -517,7 +544,7 @@ impl TurnRequestProcessor { None }; - let has_any_overrides = cwd.is_some() + let has_any_overrides = has_environment_override || runtime_workspace_roots_request.is_some() || approval_policy.is_some() || approvals_reviewer.is_some() @@ -545,7 +572,9 @@ impl TurnRequestProcessor { ))); }; let overrides = ConfigOverrides { - cwd: cwd.as_ref().map(AbsolutePathBuf::to_path_buf), + cwd: environments + .as_ref() + .map(|environments| environments.legacy_fallback_cwd.to_path_buf()), workspace_roots: Some( runtime_workspace_roots .clone() @@ -561,7 +590,7 @@ impl TurnRequestProcessor { .load_for_cwd( /*request_overrides*/ None, overrides, - Some(snapshot.cwd.to_path_buf()), + Some(snapshot.cwd().to_path_buf()), ) .await .map_err(|err| config_load_error(&err))?; @@ -588,7 +617,7 @@ impl TurnRequestProcessor { if has_any_overrides { thread .preview_thread_settings_overrides(CodexThreadSettingsOverrides { - cwd: cwd.clone(), + environments: environments.clone(), workspace_roots: runtime_workspace_roots.clone(), approval_policy, approvals_reviewer, @@ -611,7 +640,7 @@ impl TurnRequestProcessor { } Ok(codex_protocol::protocol::ThreadSettingsOverrides { - cwd, + environments, workspace_roots: runtime_workspace_roots, profile_workspace_roots, approval_policy, @@ -636,12 +665,18 @@ impl TurnRequestProcessor { ) -> Result { let (_, thread) = self.load_thread(¶ms.thread_id).await?; let cwd = resolve_request_cwd(params.cwd)?; + let environments = Self::build_environment_override( + thread.as_ref(), + cwd, + /*environment_selections*/ None, + ) + .await; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "thread/settings/update", - cwd, + environments, runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 05e3ad1c77..6ad9f1c633 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -122,6 +122,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> store .create_thread(CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index b6593bc820..c73b5593c6 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -29,6 +29,11 @@ use wiremock::matchers::method; use wiremock::matchers::path; const RESULT: &str = "cG5n"; +const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, + 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, +]; #[derive(Clone, Copy)] enum ImagegenTestMode { @@ -59,7 +64,6 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul "image_gen", "imagegen", &json!({ - "action": "generate", "prompt": "paint a blue whale", }) .to_string(), @@ -142,6 +146,67 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul Ok(()) } +#[tokio::test] +async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()> { + let edit_request = run_image_edit_test(|codex_home| { + let image_path = codex_home.join("attached.png"); + std::fs::write(&image_path, TINY_PNG_BYTES)?; + Ok(( + json!({ + "prompt": "add a red hat", + "referenced_image_paths": [image_path.display().to_string()], + }), + vec![ + V2UserInput::Text { + text: "Edit the attached image".to_string(), + text_elements: Vec::new(), + }, + V2UserInput::LocalImage { + path: image_path, + detail: None, + }, + ], + )) + }) + .await?; + assert_eq!(edit_request["prompt"], "add a red hat"); + assert!( + edit_request["images"][0]["image_url"] + .as_str() + .is_some_and(|image_url| image_url.starts_with("data:image/png;base64,")) + ); + + Ok(()) +} + +#[tokio::test] +async fn standalone_image_edit_uses_recent_pathless_image() -> Result<()> { + let image_url = "https://example.com/reference.png"; + let edit_request = run_image_edit_test(|_| { + Ok(( + json!({ + "prompt": "add a red hat", + "num_last_images_to_include": 1, + }), + vec![ + V2UserInput::Text { + text: "Edit the attached image".to_string(), + text_elements: Vec::new(), + }, + V2UserInput::Image { + url: image_url.to_string(), + detail: None, + }, + ], + )) + }) + .await?; + assert_eq!(edit_request["prompt"], "add a red hat"); + assert_eq!(edit_request["images"][0]["image_url"], image_url); + + Ok(()) +} + #[tokio::test] async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<()> { let server = responses::start_mock_server().await; @@ -202,7 +267,6 @@ async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result "exec", r#" const result = await tools.image_gen__imagegen({ - action: "generate", prompt: "paint a blue whale", }); generatedImage(result); @@ -263,6 +327,81 @@ generatedImage(result); } async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> { + start_turn( + mcp, + vec![V2UserInput::Text { + text: "Generate an image".to_string(), + text_elements: Vec::new(), + }], + ) + .await +} + +async fn run_image_edit_test( + input: impl FnOnce(&Path) -> Result<(serde_json::Value, Vec)>, +) -> Result { + let call_id = "image-edit-1"; + let server = responses::start_mock_server().await; + mount_image_edit_response(&server).await; + + let codex_home = TempDir::new()?; + let (arguments, input) = input(codex_home.path())?; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + "image_gen", + "imagegen", + &arguments.to_string(), + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + start_turn(&mut mcp, input).await?; + timeout( + DEFAULT_READ_TIMEOUT, + wait_for_image_generation_completed(&mut mcp), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!(response_mock.requests().len(), 2); + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + Ok(requests + .iter() + .find(|request| request.url.path() == "/api/codex/images/edits") + .context("image edit request should be sent")? + .body_json::()?) +} + +async fn start_turn(mcp: &mut TestAppServer, input: Vec) -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams::default()) .await?; @@ -277,10 +416,7 @@ async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> { .send_turn_start_request(TurnStartParams { thread_id: thread.id, client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Generate an image".to_string(), - text_elements: Vec::new(), - }], + input, ..Default::default() }) .await?; @@ -324,6 +460,18 @@ async fn mount_image_response(server: &MockServer) { .await; } +async fn mount_image_edit_response(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/codex/images/edits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "created": 1, + "data": [{"b64_json": RESULT}], + }))) + .expect(1) + .mount(server) + .await; +} + fn create_config_toml( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 2de63f1451..5ebe4899d0 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -288,6 +288,59 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> Ok(()) } +#[tokio::test] +async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + configure_remote_plugin_with_apps_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; + + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + + assert_eq!( + response, + PluginInstallResponse { + auth_policy: PluginAuthPolicy::OnUse, + apps_needing_auth: vec![AppSummary { + id: "alpha".to_string(), + name: "alpha".to_string(), + description: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + needs_auth: true, + }], + } + ); + wait_for_remote_plugin_request_count( + &server, + "GET", + "/backend-api/connectors/directory/list", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { let codex_home = TempDir::new()?; @@ -1398,6 +1451,34 @@ fn configure_remote_plugin_test(codex_home: &std::path::Path, server: &MockServe ) } +fn configure_remote_plugin_with_apps_test( + codex_home: &std::path::Path, + server: &MockServer, +) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true +connectors = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + ) +} + async fn mount_remote_plugin_bundle( server: &MockServer, status_code: u16, @@ -1552,6 +1633,27 @@ async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str .await; } +async fn mount_remote_plugin_install_with_apps_needing_auth( + server: &MockServer, + remote_plugin_id: &str, + app_ids_needing_auth: &[&str], +) { + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/ps/plugins/{remote_plugin_id}/install" + ))) + .and(query_param("includeAppsNeedingAuth", "true")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": remote_plugin_id, + "enabled": true, + "app_ids_needing_auth": app_ids_needing_auth, + }))) + .mount(server) + .await; +} + #[derive(Debug, Clone)] struct CacheManifestExists { manifest_path: std::path::PathBuf, 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 d9a0a00493..a49c5d3c75 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1863,6 +1863,102 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Ok(()) } +#[tokio::test] +async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000"; + let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111"; + let cached_body = + remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work"); + let refreshed_body = remote_plugin_list_body( + refreshed_remote_plugin_id, + "notion", + "Notion", + "Capture notes", + ); + mount_remote_plugin_list(&server, "GLOBAL", &cached_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: PluginListResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??, + )?; + let remote_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected warmed remote marketplace"); + assert_eq!( + remote_marketplace.plugins[0].id, + "linear@openai-curated-remote" + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?; + wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id]) + .await?; + + server.reset().await; + mount_remote_plugin_list(&server, "GLOBAL", &refreshed_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: PluginListResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??, + )?; + let remote_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected cached remote marketplace"); + assert_eq!( + remote_marketplace.plugins[0].id, + "linear@openai-curated-remote" + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?; + wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id]) + .await?; + + Ok(()) +} + #[tokio::test] async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> { let codex_home = TempDir::new()?; @@ -2275,6 +2371,92 @@ plugin_sharing = true Ok(()) } +#[tokio::test] +async fn plugin_installed_includes_workspace_directory_without_plugin_sharing() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = false +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let mut workspace_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + "LISTED", + /*enabled*/ Some(true), + ))?; + let shared_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + workspace_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(shared_installed_body["plugins"][0].clone()); + let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "workspace-directory"); + assert_eq!( + marketplace + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![( + "workspace-linear@workspace-directory".to_string(), + true, + true + )] + ); + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; + Ok(()) +} + #[tokio::test] async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> { let codex_home = TempDir::new()?; @@ -3025,6 +3207,52 @@ async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &st Ok(()) } +async fn wait_for_cached_remote_catalog_plugin_ids( + codex_home: &std::path::Path, + expected_plugin_ids: &[&str], +) -> Result<()> { + let mut expected_plugin_ids = expected_plugin_ids + .iter() + .copied() + .map(str::to_string) + .collect::>(); + expected_plugin_ids.sort(); + timeout(DEFAULT_TIMEOUT, async { + loop { + let plugin_ids = cached_remote_catalog_plugin_ids(codex_home)?; + if plugin_ids == expected_plugin_ids { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +fn cached_remote_catalog_plugin_ids(codex_home: &std::path::Path) -> Result> { + let cache_dir = codex_home.join("cache/remote_plugin_catalog"); + if !cache_dir.exists() { + return Ok(Vec::new()); + } + let mut plugin_ids = Vec::new(); + for entry in std::fs::read_dir(cache_dir)? { + let path = entry?.path(); + let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?; + let Some(plugins) = cached_catalog["plugins"].as_array() else { + continue; + }; + plugin_ids.extend( + plugins + .iter() + .filter_map(|plugin| plugin["id"].as_str()) + .map(str::to_string), + ); + } + plugin_ids.sort(); + Ok(plugin_ids) +} + async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> { timeout(DEFAULT_TIMEOUT, async { loop { @@ -3063,6 +3291,43 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) .await; } +fn remote_plugin_list_body( + remote_plugin_id: &str, + plugin_name: &str, + display_name: &str, + short_description: &str, +) -> String { + format!( + r#"{{ + "plugins": [ + {{ + "id": "{remote_plugin_id}", + "name": "{plugin_name}", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": {{ + "version": "1.2.3", + "display_name": "{display_name}", + "description": "{display_name}", + "app_ids": [], + "interface": {{ + "short_description": "{short_description}", + "capabilities": ["Read"] + }}, + "skills": [] + }} + }} + ], + "pagination": {{ + "limit": 50, + "next_page_token": null + }} +}}"# + ) +} + async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/list")) diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 0a99d30ada..272ecac79f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1356,6 +1356,7 @@ async fn seed_pathless_store_thread( store .create_thread(CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: ProtocolSessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 84fffd19ba..7267ab073a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -209,6 +209,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { store .create_thread(CreateThreadParams { thread_id, + extra_config: None, forked_from_id: Some(parent_thread_id), parent_thread_id: None, source: SessionSource::Cli, diff --git a/codex-rs/cli/BUILD.bazel b/codex-rs/cli/BUILD.bazel index a8a97cef00..faa8627477 100644 --- a/codex-rs/cli/BUILD.bazel +++ b/codex-rs/cli/BUILD.bazel @@ -3,6 +3,9 @@ load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate", "multip codex_rust_crate( name = "cli", crate_name = "codex_cli", + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], rustc_flags_extra = MACOS_WEBRTC_RUSTC_LINK_FLAGS, ) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 6d59bf7d23..a1931aced0 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -258,6 +258,9 @@ async fn run_command_under_sandbox( let network = network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy); + // Proxy containment depends on whether a proxy is active, not whether its + // policy came from managed requirements. + let enforce_managed_network = network.is_some(); let managed_mitm_ca_trust_bundle_path = match network.as_ref() { Some(network) => network.managed_mitm_ca_trust_bundle_path(), None => None, @@ -278,7 +281,7 @@ async fn run_command_under_sandbox( file_system_sandbox_policy: &file_system_sandbox_policy, network_sandbox_policy, sandbox_policy_cwd: sandbox_policy_cwd.as_path(), - enforce_managed_network: false, + enforce_managed_network, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, }); @@ -311,7 +314,7 @@ async fn run_command_under_sandbox( &runtime_permission_profile, sandbox_policy_cwd.as_path(), use_legacy_landlock, - allow_network_for_proxy(managed_network_requirements_enabled), + allow_network_for_proxy(enforce_managed_network), ); spawn_debug_sandbox_child( codex_linux_sandbox_exe, diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index dde6fad470..d817f1a192 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -682,7 +682,7 @@ fn structured_json_details(details: &[String]) -> (BTreeMap existing.push(value), None => { @@ -693,6 +693,21 @@ fn structured_json_details(details: &[String]) -> (BTreeMap String { + if matches!( + key, + "VISUAL" | "EDITOR" | "PAGER" | "GIT_PAGER" | "GH_PAGER" | "LESS" + ) && !value.eq_ignore_ascii_case("not set") + { + // Editor and pager configuration can contain arbitrary arguments or + // inline environment assignments. Keep full values local to human output + // because the JSON report may be attached to feedback. + "set".to_string() + } else { + value.to_string() + } +} + fn run_sync_check( label: &'static str, progress: Arc, @@ -3226,6 +3241,18 @@ mod tests { overall_status: CheckStatus::Warning, codex_version: "0.0.0".to_string(), checks: vec![ + DoctorCheck::new( + "system.environment", + "system", + CheckStatus::Ok, + "OS language en-US", + ) + .detail("VISUAL: code --wait") + .detail("EDITOR: env AWS_ACCESS_KEY_ID=AKIAEXAMPLE vim") + .detail("PAGER: env PRIVATE_PAGER_VALUE=pager-secret less") + .detail("GIT_PAGER: delta") + .detail("GH_PAGER: less") + .detail("LESS: -FRX"), DoctorCheck::new( "mcp.config", "mcp", @@ -3260,8 +3287,35 @@ mod tests { assert!(!redacted.contains("user:pass")); assert!(!redacted.contains("x=abc")); assert!(!redacted.contains("sk-live-secret")); + assert!(!redacted.contains("AKIAEXAMPLE")); + assert!(!redacted.contains("pager-secret")); + assert!(!redacted.contains("code --wait")); assert!(redacted.contains("https://example.com/mcp")); assert_eq!(json["checks"].is_object(), true); + assert_eq!( + json["checks"]["system.environment"]["details"]["VISUAL"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["EDITOR"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["PAGER"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["GIT_PAGER"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["GH_PAGER"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["LESS"], + "set" + ); assert_eq!(json["checks"]["mcp.config"]["id"], "mcp.config"); assert_eq!( json["checks"]["mcp.config"]["details"]["OPENAI_API_KEY"], diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index 3f6e2f05d2..81f9231482 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -1133,7 +1133,13 @@ mod tests { "OS language en-US", ) .detail("os: macOS 15.0") - .detail("os language: en-US"), + .detail("os language: en-US") + .detail("VISUAL: code --wait") + .detail("EDITOR: vim") + .detail("PAGER: less -R") + .detail("GIT_PAGER: delta") + .detail("GH_PAGER: less") + .detail("LESS: -FRX"), DoctorCheck::new( "runtime.provenance", "runtime", @@ -1246,6 +1252,12 @@ Environment ✓ system en-US os macOS 15.0 OS language en-US + VISUAL code --wait + EDITOR vim + PAGER less -R + GIT_PAGER delta + GH_PAGER less + LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent managed by npm: no · bun: no · package root — diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs index 6c6cf8633c..05e7af404e 100644 --- a/codex-rs/cli/src/doctor/output/detail.rs +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -62,6 +62,12 @@ fn system_details(parsed: &[ParsedDetail]) -> Vec { push_row_if_present(&mut out, parsed, "LC_ALL", "LC_ALL"); push_row_if_present(&mut out, parsed, "LC_CTYPE", "LC_CTYPE"); push_row_if_present(&mut out, parsed, "LANG", "LANG"); + push_row_if_present(&mut out, parsed, "VISUAL", "VISUAL"); + push_row_if_present(&mut out, parsed, "EDITOR", "EDITOR"); + push_row_if_present(&mut out, parsed, "PAGER", "PAGER"); + push_row_if_present(&mut out, parsed, "GIT_PAGER", "GIT_PAGER"); + push_row_if_present(&mut out, parsed, "GH_PAGER", "GH_PAGER"); + push_row_if_present(&mut out, parsed, "LESS", "LESS"); push_remaining( &mut out, parsed, @@ -73,6 +79,12 @@ fn system_details(parsed: &[ParsedDetail]) -> Vec { "LC_ALL", "LC_CTYPE", "LANG", + "VISUAL", + "EDITOR", + "PAGER", + "GIT_PAGER", + "GH_PAGER", + "LESS", ], &[], ); diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap index 84d3441fb2..b3c8d1ee78 100644 --- a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -13,6 +13,12 @@ Environment ✓ system en-US os macOS 15.0 OS language en-US + VISUAL code --wait + EDITOR vim + PAGER less -R + GIT_PAGER delta + GH_PAGER less + LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent managed by npm: no · bun: no · package root — diff --git a/codex-rs/cli/src/doctor/system.rs b/codex-rs/cli/src/doctor/system.rs index c72b76561d..cbab5aa1bf 100644 --- a/codex-rs/cli/src/doctor/system.rs +++ b/codex-rs/cli/src/doctor/system.rs @@ -4,6 +4,9 @@ use std::env; use super::DoctorCheck; use super::LOCALE_ENV_VARS; +const EDITOR_ENV_VARS: &[&str] = &["VISUAL", "EDITOR"]; +const PAGER_ENV_VARS: &[&str] = &["PAGER", "GIT_PAGER", "GH_PAGER", "LESS"]; + #[derive(Clone, Debug, Default, Eq, PartialEq)] struct SystemCheckInputs { os: String, @@ -11,6 +14,8 @@ struct SystemCheckInputs { os_version: String, os_language: Option, locale_env: BTreeMap, + editor_env: BTreeMap, + pager_env: BTreeMap, } impl SystemCheckInputs { @@ -24,12 +29,30 @@ impl SystemCheckInputs { .map(|value| ((*name).to_string(), value)) }) .collect(); + let editor_env = EDITOR_ENV_VARS + .iter() + .map(|name| { + let value = env::var_os(name) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "not set".to_string()); + ((*name).to_string(), value) + }) + .collect(); + let pager_env = PAGER_ENV_VARS + .iter() + .filter_map(|name| { + env::var_os(name) + .map(|value| ((*name).to_string(), value.to_string_lossy().into_owned())) + }) + .collect(); Self { os: info.to_string(), os_type: info.os_type().to_string(), os_version: info.version().to_string(), os_language: sys_locale::get_locale(), locale_env, + editor_env, + pager_env, } } } @@ -54,6 +77,16 @@ fn system_check_from_inputs(inputs: SystemCheckInputs) -> DoctorCheck { details.push(format!("{name}: {value}")); } } + for name in EDITOR_ENV_VARS { + if let Some(value) = inputs.editor_env.get(*name) { + details.push(format!("{name}: {value}")); + } + } + for name in PAGER_ENV_VARS { + if let Some(value) = inputs.pager_env.get(*name) { + details.push(format!("{name}: {value}")); + } + } let summary = inputs .os_language @@ -76,20 +109,46 @@ mod tests { use super::*; #[test] - fn system_check_reports_os_language_and_locale_env() { + fn system_check_reports_os_language_locale_editor_and_pager_env() { let mut locale_env = BTreeMap::new(); locale_env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); + let editor_env = BTreeMap::from([ + ("EDITOR".to_string(), "vim".to_string()), + ("VISUAL".to_string(), "code --wait".to_string()), + ]); + let pager_env = BTreeMap::from([ + ("GH_PAGER".to_string(), "less".to_string()), + ("GIT_PAGER".to_string(), "delta".to_string()), + ("LESS".to_string(), "-FRX".to_string()), + ("PAGER".to_string(), "less -R".to_string()), + ]); let check = system_check_from_inputs(SystemCheckInputs { os: "macOS 15.0".to_string(), os_type: "macos".to_string(), os_version: "15.0".to_string(), os_language: Some("en-US".to_string()), locale_env, + editor_env, + pager_env, }); assert_eq!(check.summary, "OS language en-US"); - assert!(check.details.contains(&"os language: en-US".to_string())); - assert!(check.details.contains(&"LANG: en_US.UTF-8".to_string())); + assert_eq!( + check.details, + vec![ + "os: macOS 15.0", + "os type: macos", + "os version: 15.0", + "os language: en-US", + "LANG: en_US.UTF-8", + "VISUAL: code --wait", + "EDITOR: vim", + "PAGER: less -R", + "GIT_PAGER: delta", + "GH_PAGER: less", + "LESS: -FRX", + ] + ); } #[test] @@ -100,13 +159,24 @@ mod tests { os_version: "unknown".to_string(), os_language: None, locale_env: BTreeMap::new(), + editor_env: BTreeMap::from([ + ("EDITOR".to_string(), "not set".to_string()), + ("VISUAL".to_string(), "not set".to_string()), + ]), + pager_env: BTreeMap::new(), }); assert_eq!(check.summary, "OS language unavailable"); - assert!( - check - .details - .contains(&"os language: unavailable".to_string()) + assert_eq!( + check.details, + vec![ + "os: Linux", + "os type: linux", + "os version: unknown", + "os language: unavailable", + "VISUAL: not set", + "EDITOR: not set", + ] ); } } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 5e2ba0caa5..4e63df187e 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -26,7 +26,7 @@ pub use login::run_logout; #[derive(Debug, Parser)] pub struct SeatbeltCommand { /// Named permissions profile to apply from the active configuration stack. - #[arg(long = "permissions-profile", value_name = "NAME")] + #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, /// Layer $CODEX_HOME/.config.toml on top of the base user config. @@ -74,7 +74,7 @@ fn parse_allow_unix_socket_path(raw: &str) -> Result { #[derive(Debug, Parser)] pub struct LandlockCommand { /// Named permissions profile to apply from the active configuration stack. - #[arg(long = "permissions-profile", value_name = "NAME")] + #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, /// Layer $CODEX_HOME/.config.toml on top of the base user config. @@ -109,7 +109,7 @@ pub struct LandlockCommand { #[derive(Debug, Parser)] pub struct WindowsCommand { /// Named permissions profile to apply from the active configuration stack. - #[arg(long = "permissions-profile", value_name = "NAME")] + #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, /// Layer $CODEX_HOME/.config.toml on top of the base user config. diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index bb0daf3890..b194a5ab1b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2880,6 +2880,21 @@ mod tests { assert_eq!(command.command, vec!["echo"]); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[test] + fn sandbox_parses_permissions_profile_short_alias() { + let cli = + MultitoolCli::try_parse_from(["codex", "sandbox", "-P", ":workspace", "--", "echo"]) + .expect("parse"); + + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); + }; + + assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); + assert_eq!(command.command, vec!["echo"]); + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn sandbox_parses_config_profile() { diff --git a/codex-rs/cli/tests/sandbox_network_proxy.rs b/codex-rs/cli/tests/sandbox_network_proxy.rs new file mode 100644 index 0000000000..22bdcce49d --- /dev/null +++ b/codex-rs/cli/tests/sandbox_network_proxy.rs @@ -0,0 +1,70 @@ +#![cfg(target_os = "linux")] + +use std::net::TcpListener; + +use anyhow::Result; +use tempfile::TempDir; + +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable"; + +#[test] +fn sandbox_with_network_proxy_blocks_direct_loopback_access() -> Result<()> { + let codex_home = TempDir::new()?; + let listener = TcpListener::bind("127.0.0.2:0")?; + let port = listener.local_addr()?.port(); + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = "network-test" + +[features] +network_proxy = true +use_legacy_landlock = true + +[permissions.network-test] +extends = ":workspace" + +[permissions.network-test.network] +enabled = true +mode = "full" +"#, + )?; + + let url = format!("http://127.0.0.2:{port}/"); + let output = std::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?) + .env("CODEX_HOME", codex_home.path()) + .args([ + "sandbox", + "--permissions-profile", + "network-test", + "--", + "curl", + "--noproxy", + "*", + "--silent", + "--show-error", + "--connect-timeout", + "1", + "--max-time", + "2", + url.as_str(), + ]) + .output()?; + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains(BWRAP_UNAVAILABLE_ERR) { + eprintln!("skipping network proxy sandbox test: bubblewrap is unavailable"); + return Ok(()); + } + + assert_eq!( + output.status.code(), + Some(7), + "expected direct loopback access to be blocked; status={:?}; stdout={}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + stderr, + ); + + Ok(()) +} diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index d1c7c7a18b..47452eb032 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -35,6 +35,7 @@ pub use codex_core::ThreadManager; pub use codex_core::ThreadShutdownReport; pub use codex_core::config::Config; pub use codex_core::config::Constrained; +pub use codex_core::config::ExtraConfig; pub use codex_core::config::GhostSnapshotConfig; pub use codex_core::config::MultiAgentV2Config; pub use codex_core::config::Permissions; diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index f376d099ce..28a90c1928 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -14,7 +14,6 @@ use crate::PluginsConfigInput; use crate::PluginsManager; use crate::marketplace::MarketplacePluginInstallPolicy; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; -use crate::remote::RemotePluginScope; const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ "github@openai-curated", @@ -100,7 +99,9 @@ impl PluginsManager { .collect::>(); installed_app_connector_ids.extend(input.loaded_plugin_app_connector_ids.iter().cloned()); let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled { - self.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) + self.build_remote_installed_plugin_marketplaces_from_cache(&[ + REMOTE_GLOBAL_MARKETPLACE_NAME, + ]) } else { None }; diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 8cfb9092f6..84eeea5dc9 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -34,6 +34,7 @@ pub use manager::PluginDetailsUnavailableReason; pub use manager::PluginInstallError; pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; +pub use manager::PluginListBackgroundTaskOptions; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; pub use manager::PluginUninstallError; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index d6414be6ff..9dfa0bbc6f 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -5,6 +5,7 @@ use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; use crate::marketplace::list_marketplaces; use crate::marketplace::load_marketplace; +use crate::marketplace::load_raw_marketplace_plugin_names; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; use crate::store::PluginStore; @@ -307,6 +308,10 @@ pub fn refresh_curated_plugin_cache( .join(".agents/plugins/marketplace.json"), ) .map_err(|_| "local curated marketplace is not available".to_string())?; + let marketplace_plugin_names = load_raw_marketplace_plugin_names(&curated_marketplace_path) + .map_err(|err| { + format!("failed to load curated marketplace plugin names for cache refresh: {err}") + })?; let curated_marketplace = load_marketplace(&curated_marketplace_path) .map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?; @@ -321,36 +326,40 @@ pub fn refresh_curated_plugin_cache( ); continue; } - let source_path = match plugin.source { - MarketplacePluginSource::Local { path } => path, - MarketplacePluginSource::Git { .. } => { - warn!( - plugin = plugin_name, - marketplace = OPENAI_CURATED_MARKETPLACE_NAME, - "skipping remote curated plugin source during cache refresh" - ); - continue; - } - }; - plugin_sources.insert(plugin_name, source_path); + if let MarketplacePluginSource::Local { path } = plugin.source { + plugin_sources.insert(plugin_name, path); + } } let mut cache_refreshed = false; for plugin_id in configured_curated_plugin_ids { - if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str()) - { - continue; - } - - let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { + if !marketplace_plugin_names.contains(&plugin_id.plugin_name) { warn!( plugin = plugin_id.plugin_name, marketplace = OPENAI_CURATED_MARKETPLACE_NAME, "configured curated plugin no longer exists in curated marketplace during cache refresh" ); + if store.plugin_base_root(plugin_id).as_path().exists() { + store.uninstall(plugin_id).map_err(|err| { + format!( + "failed to remove stale curated plugin cache for {}: {err}", + plugin_id.as_key() + ) + })?; + cache_refreshed = true; + } + continue; + } + + let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { continue; }; + if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str()) + { + continue; + } + store .install_with_version(source_path, plugin_id.clone(), cache_plugin_version.clone()) .map_err(|err| { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 1839b06e02..095c5fd69d 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -38,7 +38,6 @@ use crate::marketplace_upgrade::configured_git_marketplace_names; use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; use crate::remote::RemoteInstalledPlugin; use crate::remote::RemotePluginCatalogError; -use crate::remote::RemotePluginScope; use crate::remote::RemotePluginServiceConfig; use crate::remote_legacy::RemotePluginFetchError; use crate::remote_legacy::RemotePluginMutationError; @@ -146,6 +145,22 @@ struct RemoteInstalledPluginsCacheRefreshState { in_flight: bool, } +struct GlobalRemoteCatalogCacheRefreshRequest { + service_config: RemotePluginServiceConfig, + auth: Option, +} + +#[derive(Default)] +struct GlobalRemoteCatalogCacheRefreshState { + requested: Option, + in_flight: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PluginListBackgroundTaskOptions { + pub refresh_global_remote_catalog_cache: bool, +} + #[derive(Clone, PartialEq, Eq)] struct NonCuratedCacheRefreshRequest { roots: Vec, @@ -302,6 +317,7 @@ pub struct PluginsManager { enabled_outcome_load_semaphore: Semaphore, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, + global_remote_catalog_cache_refresh_state: RwLock, restriction_product: Option, analytics_events_client: RwLock>, } @@ -355,6 +371,9 @@ impl PluginsManager { remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), ), + global_remote_catalog_cache_refresh_state: RwLock::new( + GlobalRemoteCatalogCacheRefreshState::default(), + ), restriction_product, analytics_events_client: RwLock::new(None), } @@ -540,14 +559,19 @@ impl PluginsManager { pub fn build_remote_installed_plugin_marketplaces_from_cache( &self, - visible_scopes: &[RemotePluginScope], + visible_marketplaces: &[&str], ) -> Option> { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; let plugins = cache.as_ref()?; - Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes)) + Some( + crate::remote::group_remote_installed_plugins_by_marketplaces( + plugins, + visible_marketplaces, + ), + ) } pub fn cached_global_remote_discoverable_plugins_for_config( @@ -579,7 +603,7 @@ impl PluginsManager { &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, - visible_scopes: &[RemotePluginScope], + visible_marketplaces: &[&str], on_effective_plugins_changed: Option>, ) -> Result, RemotePluginCatalogError> { let plugins = crate::remote::fetch_remote_installed_plugins( @@ -587,8 +611,10 @@ impl PluginsManager { auth, ) .await?; - let marketplaces = - crate::remote::group_remote_installed_plugins_by_marketplaces(&plugins, visible_scopes); + let marketplaces = crate::remote::group_remote_installed_plugins_by_marketplaces( + &plugins, + visible_marketplaces, + ); let changed = self.write_remote_installed_plugins_cache(plugins); if changed && let Some(on_effective_plugins_changed) = on_effective_plugins_changed { on_effective_plugins_changed(); @@ -702,14 +728,33 @@ impl PluginsManager { ); } + fn maybe_start_global_remote_catalog_cache_refresh( + self: &Arc, + config: &PluginsConfigInput, + auth: Option, + ) { + if !config.plugins_enabled || !config.remote_plugin_enabled { + return; + } + + self.schedule_global_remote_catalog_cache_refresh(GlobalRemoteCatalogCacheRefreshRequest { + service_config: remote_plugin_service_config(config), + auth, + }); + } + pub fn maybe_start_plugin_list_background_tasks_for_config( self: &Arc, config: &PluginsConfigInput, auth: Option, roots: &[AbsolutePathBuf], + options: PluginListBackgroundTaskOptions, on_effective_plugins_changed: Option>, ) { self.maybe_start_non_curated_plugin_cache_refresh(roots); + if options.refresh_global_remote_catalog_cache { + self.maybe_start_global_remote_catalog_cache_refresh(config, auth.clone()); + } self.maybe_start_remote_installed_plugins_cache_refresh( config, auth.clone(), @@ -1447,6 +1492,33 @@ impl PluginsManager { }); } + fn schedule_global_remote_catalog_cache_refresh( + self: &Arc, + request: GlobalRemoteCatalogCacheRefreshRequest, + ) { + let should_spawn = { + let mut state = match self.global_remote_catalog_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.requested = Some(request); + if state.in_flight { + false + } else { + state.in_flight = true; + true + } + }; + if !should_spawn { + return; + } + + let manager = Arc::clone(self); + tokio::spawn(async move { + manager.run_global_remote_catalog_cache_refresh_loop().await; + }); + } + fn schedule_non_curated_plugin_cache_refresh( self: &Arc, roots: &[AbsolutePathBuf], @@ -1613,6 +1685,44 @@ impl PluginsManager { } } + async fn run_global_remote_catalog_cache_refresh_loop(self: Arc) { + loop { + let request = { + let mut state = match self.global_remote_catalog_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + match state.requested.take() { + Some(request) => request, + None => { + state.in_flight = false; + return; + } + } + }; + + match crate::remote::fetch_and_cache_global_remote_plugin_catalog( + self.codex_home.as_path(), + &request.service_config, + request.auth.as_ref(), + ) + .await + { + Ok(()) => {} + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "failed to refresh cached global remote plugin catalog" + ); + } + } + } + } + fn run_non_curated_plugin_cache_refresh_loop(self: Arc) { loop { let request = { diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index b1be37655b..c3961796cf 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -6,8 +6,10 @@ use crate::loader::load_plugins_from_layer_stack; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; use crate::marketplace::MarketplacePluginInstallPolicy; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; -use crate::remote::RemotePluginScope; use crate::startup_sync::curated_plugins_repo_path; use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; use crate::test_support::TEST_CURATED_PLUGIN_SHA; @@ -137,8 +139,15 @@ fn remote_installed_linear_plugin() -> RemoteInstalledPlugin { } fn remote_installed_plugin(name: &str) -> RemoteInstalledPlugin { + remote_installed_plugin_in_marketplace(name, REMOTE_GLOBAL_MARKETPLACE_NAME) +} + +fn remote_installed_plugin_in_marketplace( + name: &str, + marketplace_name: &str, +) -> RemoteInstalledPlugin { RemoteInstalledPlugin { - marketplace_name: "openai-curated-remote".to_string(), + marketplace_name: marketplace_name.to_string(), id: format!("plugins~Plugin_{name}"), name: name.to_string(), enabled: true, @@ -484,7 +493,7 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad manager.write_remote_installed_plugins_cache(vec![plugin]); let marketplaces = manager - .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) + .build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_GLOBAL_MARKETPLACE_NAME]) .expect("remote installed cache should be present"); assert_eq!(marketplaces.len(), 1); assert_eq!(marketplaces[0].name, "openai-curated-remote"); @@ -521,12 +530,45 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad ); assert_eq!( manager - .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Workspace]) + .build_remote_installed_plugin_marketplaces_from_cache(&[ + REMOTE_WORKSPACE_MARKETPLACE_NAME + ]) .expect("remote installed cache should be present"), Vec::new() ); } +#[tokio::test] +async fn build_remote_installed_plugin_marketplaces_from_cache_filters_by_marketplace_name() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![ + remote_installed_plugin_in_marketplace( + "workspace-linear", + REMOTE_WORKSPACE_MARKETPLACE_NAME, + ), + remote_installed_plugin_in_marketplace( + "shared-linear", + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + ), + ]); + + let marketplaces = manager + .build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_WORKSPACE_MARKETPLACE_NAME]) + .expect("remote installed cache should be present"); + + assert_eq!(marketplaces.len(), 1); + assert_eq!(marketplaces[0].name, REMOTE_WORKSPACE_MARKETPLACE_NAME); + assert_eq!( + marketplaces[0] + .plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["workspace-linear@workspace-directory"] + ); +} + #[tokio::test] async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { let codex_home = TempDir::new().unwrap(); @@ -3039,6 +3081,33 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren ); } +#[test] +fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &[]); + let plugin_id = PluginId::new( + "google-sheets".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + let plugin_cache_root = tmp + .path() + .join("plugins/cache/openai-curated/google-sheets"); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + &format!("google-sheets/{TEST_CURATED_PLUGIN_CACHE_VERSION}"), + "google-sheets", + ); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should remove stale configured plugin") + ); + + assert!(!plugin_cache_root.exists()); +} + #[test] fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 49925a673f..049d882751 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde_json::Value as JsonValue; +use std::collections::HashSet; use std::fs; use std::io; use std::path::Component; @@ -326,6 +327,16 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result Result, MarketplaceError> { + Ok(load_raw_marketplace_manifest(path)? + .plugins + .into_iter() + .map(|plugin| plugin.name) + .collect()) +} + #[doc(hidden)] pub fn list_marketplaces_with_home( additional_roots: &[AbsolutePathBuf], diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index b96746b637..1885318727 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -550,6 +550,12 @@ struct RemotePluginInstalledResponse { struct RemotePluginMutationResponse { id: String, enabled: bool, + app_ids_needing_auth: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginInstallResult { + pub app_ids_needing_auth: Option>, } pub async fn fetch_remote_marketplaces( @@ -576,6 +582,25 @@ pub async fn fetch_remote_marketplaces( match source { RemoteMarketplaceSource::Global => { let scope = RemotePluginScope::Global; + if let Some(codex_home) = global_catalog_cache_path + && let Some(directory_plugins) = + catalog_cache::load_cached_global_directory_plugins( + codex_home, config, auth, + ) + { + let installed_plugins = + fetch_installed_plugins_for_scope(config, auth, scope).await?; + if let Some(marketplace) = build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + installed_plugins, + /*include_installed_only*/ true, + )? { + marketplaces.push(marketplace); + } + continue; + } let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope(config, auth, scope), fetch_installed_plugins_for_scope(config, auth, scope), @@ -692,6 +717,17 @@ pub async fn fetch_and_cache_global_remote_plugin_catalog( Ok(()) } +pub fn has_cached_global_remote_plugin_catalog( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> bool { + let Ok(auth) = ensure_chatgpt_auth(auth) else { + return false; + }; + catalog_cache::load_cached_global_directory_plugins(codex_home, config, auth).is_some() +} + pub fn cached_global_remote_discoverable_plugins( codex_home: &Path, config: &RemotePluginServiceConfig, @@ -815,14 +851,12 @@ pub(crate) async fn fetch_remote_installed_plugins( pub fn group_remote_installed_plugins_by_marketplaces( plugins: &[RemoteInstalledPlugin], - visible_scopes: &[RemotePluginScope], + visible_marketplaces: &[&str], ) -> Vec { let mut plugins_by_marketplace = BTreeMap::>::new(); for plugin in plugins { - if !RemotePluginScope::from_marketplace_name(&plugin.marketplace_name) - .is_some_and(|scope| visible_scopes.contains(&scope)) - { + if !visible_marketplaces.contains(&plugin.marketplace_name.as_str()) { continue; } let Ok(plugin_id) = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) @@ -1041,7 +1075,7 @@ pub async fn install_remote_plugin( auth: Option<&CodexAuth>, _marketplace_name: &str, plugin_id: &str, -) -> Result<(), RemotePluginCatalogError> { +) -> Result { let auth = ensure_chatgpt_auth(auth)?; // Remote plugin IDs uniquely identify remote plugins, so the caller-provided // marketplace name is not validated before sending the install mutation. @@ -1049,7 +1083,12 @@ pub async fn install_remote_plugin( let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); let client = build_reqwest_client(); - let request = authenticated_request(client.post(&url), auth)?; + let request = authenticated_request( + client + .post(&url) + .query(&[("includeAppsNeedingAuth", "true")]), + auth, + )?; let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { @@ -1065,7 +1104,9 @@ pub async fn install_remote_plugin( }); } - Ok(()) + Ok(RemotePluginInstallResult { + app_ids_needing_auth: response.app_ids_needing_auth, + }) } pub async fn uninstall_remote_plugin( diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 28617fb6c4..1eff1f879f 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -28,16 +28,16 @@ pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The li - Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. - Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. - How to use a skill (progressive disclosure): - 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 1) After deciding to use a skill, the main agent must open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF. 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed. - 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it. 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. - Coordination and sequencing: - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. - Context hygiene: - - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets. - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; @@ -45,16 +45,16 @@ pub const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r###"- Discovery: The list abov - Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. - Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. - How to use a skill (progressive disclosure): - 1) After deciding to use a skill, expand the listed short `path` with the matching alias from `### Skill roots`, then open its `SKILL.md`. Read only enough to follow the workflow. + 1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF. 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed. - 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it. 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. - Coordination and sequencing: - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. - Context hygiene: - - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets. - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; @@ -982,6 +982,30 @@ mod tests { ) } + #[test] + fn skill_usage_instructions_require_complete_main_agent_reads() { + for instructions in [ + SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS, + SKILLS_HOW_TO_USE_WITH_ALIASES, + ] { + assert!(instructions.contains("read its `SKILL.md` completely")); + assert!(instructions.contains("continue until EOF")); + assert!(instructions.contains( + "The main agent must read each required instruction or reference file itself" + )); + assert!(instructions.contains( + "Do not delegate reading, summarizing, or interpreting skill instructions" + )); + assert!(instructions.contains( + "Subagents may still perform task work when the selected skill allows it" + )); + assert!(instructions.contains( + "Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file" + )); + assert!(!instructions.contains("Read only enough to follow the workflow")); + } + } + #[test] fn default_budget_uses_two_percent_of_full_context_window() { assert_eq!( diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3ad1360e5a..c626dda4a5 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -458,7 +458,6 @@ async fn send_input_submits_user_message() { let expected = ( thread_id, Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello from tests".to_string(), text_elements: Vec::new(), @@ -797,7 +796,6 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { let expected = ( thread_id, Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "spawned".to_string(), text_elements: Vec::new(), @@ -1017,7 +1015,6 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let expected = ( child_thread_id, Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "child task".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5a1473b057..5f816bd075 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -196,7 +196,6 @@ pub(crate) async fn run_codex_thread_one_shot( // Send the initial input to kick off the one-shot turn. io.submit(Op::UserInput { - environments: None, items: input, final_output_json_schema, responsesapi_client_metadata: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1697f768e2..ab1f6df03e 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -32,6 +32,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; use codex_thread_store::StoredThread; @@ -59,7 +60,7 @@ pub struct ThreadConfigSnapshot { pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, pub active_permission_profile: Option, - pub cwd: AbsolutePathBuf, + pub environments: TurnEnvironmentSelections, pub workspace_roots: Vec, pub profile_workspace_roots: Vec, pub ephemeral: bool, @@ -114,10 +115,18 @@ impl TryStartTurnIfIdleError { } impl ThreadConfigSnapshot { + pub fn cwd(&self) -> &AbsolutePathBuf { + &self.environments.legacy_fallback_cwd + } + + pub fn environment_selections(&self) -> &[TurnEnvironmentSelection] { + &self.environments.environments + } + pub fn sandbox_policy(&self) -> SandboxPolicy { codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &self.permission_profile, - self.cwd.as_path(), + self.cwd().as_path(), ) } } @@ -125,7 +134,7 @@ impl ThreadConfigSnapshot { /// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] pub struct CodexThreadSettingsOverrides { - pub cwd: Option, + pub environments: Option, pub workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, @@ -330,7 +339,7 @@ impl CodexThread { overrides: CodexThreadSettingsOverrides, ) -> SessionSettingsUpdate { let CodexThreadSettingsOverrides { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, @@ -357,7 +366,7 @@ impl CodexThread { }; SessionSettingsUpdate { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 5fca191b84..bd852b4e79 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -8,8 +8,6 @@ use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::context_manager::ContextManager; -use crate::context_manager::TotalTokenUsageBreakdown; -use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; @@ -35,9 +33,7 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; -use futures::TryFutureExt; use tokio_util::sync::CancellationToken; -use tracing::error; use tracing::info; const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = @@ -199,9 +195,8 @@ async fn run_remote_compact_task_inner_impl( } if estimated_deleted_tokens > 0 { let max_local_deleted_tokens = sess - .get_total_token_usage_breakdown() - .await - .estimated_tokens_of_items_added_since_last_successful_api_response; + .estimated_tokens_after_last_model_generated_item() + .await; analytics_details.active_context_tokens_before = analytics_details .active_context_tokens_before .map(|active_context_tokens_before| { @@ -252,18 +247,6 @@ async fn run_remote_compact_task_inner_impl( &compaction_trace, turn_metadata_header.as_deref(), ) - .or_else(|err| async { - let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; - let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); - log_remote_compact_failure( - turn_context, - &compact_request_log_data, - total_usage_breakdown, - &err, - ); - Err(err) - }) .await?; new_history = process_compacted_history( sess.as_ref(), @@ -363,47 +346,6 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } } -#[derive(Debug)] -pub(crate) struct CompactRequestLogData { - failing_compaction_request_model_visible_bytes: i64, -} - -pub(crate) fn build_compact_request_log_data( - input: &[ResponseItem], - instructions: &str, -) -> CompactRequestLogData { - let failing_compaction_request_model_visible_bytes = input - .iter() - .map(estimate_response_item_model_visible_bytes) - .fold( - i64::try_from(instructions.len()).unwrap_or(i64::MAX), - i64::saturating_add, - ); - - CompactRequestLogData { - failing_compaction_request_model_visible_bytes, - } -} - -pub(crate) fn log_remote_compact_failure( - turn_context: &TurnContext, - log_data: &CompactRequestLogData, - total_usage_breakdown: TotalTokenUsageBreakdown, - err: &CodexErr, -) { - error!( - turn_id = %turn_context.sub_id, - last_api_response_total_tokens = total_usage_breakdown.last_api_response_total_tokens, - all_history_items_model_visible_bytes = total_usage_breakdown.all_history_items_model_visible_bytes, - estimated_tokens_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_tokens_of_items_added_since_last_successful_api_response, - estimated_bytes_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_bytes_of_items_added_since_last_successful_api_response, - model_context_window_tokens = ?turn_context.model_context_window(), - failing_compaction_request_model_visible_bytes = log_data.failing_compaction_request_model_visible_bytes, - compact_error = %err, - "remote compaction failed" - ); -} - pub(crate) fn trim_function_call_history_to_fit_context_window( history: &mut ContextManager, turn_context: &TurnContext, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 1d4eb8ad5a..a80d4dd56f 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -8,8 +8,6 @@ use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; -use crate::compact_remote::build_compact_request_log_data; -use crate::compact_remote::log_remote_compact_failure; use crate::compact_remote::process_compacted_history; use crate::compact_remote::should_keep_compacted_history_item; use crate::compact_remote::trim_function_call_history_to_fit_context_window; @@ -212,9 +210,8 @@ async fn run_remote_compact_task_inner_impl( } if estimated_deleted_tokens > 0 { let max_local_deleted_tokens = sess - .get_total_token_usage_breakdown() - .await - .estimated_tokens_of_items_added_since_last_successful_api_response; + .estimated_tokens_after_last_model_generated_item() + .await; analytics_details.active_context_tokens_before = analytics_details .active_context_tokens_before .map(|active_context_tokens_before| { @@ -354,12 +351,9 @@ async fn run_remote_compaction_request_v2( match result { Ok(compaction_output) => return Ok(compaction_output), - Err(err) if !err.is_retryable() => { - log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; - return Err(err); - } + Err(err) if !err.is_retryable() => return Err(err), Err(err) => { - if let Err(err) = handle_retryable_response_stream_error( + handle_retryable_response_stream_error( &mut retries, max_retries, err, @@ -368,33 +362,12 @@ async fn run_remote_compaction_request_v2( turn_context, ResponsesStreamRequest::RemoteCompactionV2, ) - .await - { - log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; - return Err(err); - } + .await?; } } } } -async fn log_remote_compaction_request_failure( - sess: &Session, - turn_context: &TurnContext, - prompt: &Prompt, - err: &CodexErr, -) { - let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; - let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); - log_remote_compact_failure( - turn_context, - &compact_request_log_data, - total_usage_breakdown, - err, - ); -} - async fn collect_compaction_output( mut stream: ResponseStream, ) -> CodexResult { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fd74fcf2d2..93e203053f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -103,6 +103,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SandboxPolicy; +pub use codex_thread_store::ExtraConfig; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use rmcp::model::ElicitationCapability; @@ -202,7 +203,7 @@ All agents in the team, including the agents that you can assign tasks to, are e You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. Child agents can also spawn their own sub-agents. You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. -Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. +Default to doing the work yourself. Spawn sub-agents only for concrete, bounded subtasks that can run independently alongside useful local work and are likely to materially shorten completion time. Do not delegate simple tasks, small edits, routine searches, or work you can complete quickly yourself. You will receive messages in the analysis channel in the form: ``` @@ -219,7 +220,7 @@ You can spawn sub-agents to handle subtasks, and those sub-agents can spawn thei You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. Child agents can also spawn their own sub-agents. -Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. +Default to doing the work yourself. Spawn sub-agents only for concrete, bounded subtasks that can run independently alongside useful local work and are likely to materially shorten completion time. Do not delegate simple tasks, small edits, routine searches, or work you can complete quickly yourself. When you provide a response in the final channel, that content is immediately delivered back to your parent agent. @@ -870,6 +871,9 @@ pub struct Config { /// When true, session is not persisted on disk. Default to `false` pub ephemeral: bool, + /// Optional extra configuration fields for the thread. + pub extra_config: Option, + /// Whether enabled hooks should run without requiring persisted hook trust for this session. /// /// This is a runtime-only knob populated from invocation overrides, not from config files. @@ -3525,6 +3529,7 @@ impl Config { config_layer_stack, history, ephemeral: ephemeral.unwrap_or_default(), + extra_config: None, bypass_hook_trust, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_self_exe, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index b8bcf9c846..479ccc89c6 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -50,14 +50,6 @@ pub(crate) struct ContextManager { reference_context_item: Option, } -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct TotalTokenUsageBreakdown { - pub last_api_response_total_tokens: i64, - pub all_history_items_model_visible_bytes: i64, - pub estimated_tokens_of_items_added_since_last_successful_api_response: i64, - pub estimated_bytes_of_items_added_since_last_successful_api_response: i64, -} - impl ContextManager { pub(crate) fn new() -> Self { Self { @@ -321,32 +313,11 @@ impl ContextManager { } } - pub(crate) fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown { - let last_usage = self - .token_info - .as_ref() - .map(|info| info.last_token_usage.clone()) - .unwrap_or_default(); - let items_after_last_model_generated = self.items_after_last_model_generated_item(); - - TotalTokenUsageBreakdown { - last_api_response_total_tokens: last_usage.total_tokens, - all_history_items_model_visible_bytes: self - .items - .iter() - .map(estimate_response_item_model_visible_bytes) - .fold(0i64, i64::saturating_add), - estimated_tokens_of_items_added_since_last_successful_api_response: - items_after_last_model_generated - .iter() - .map(estimate_item_token_count) - .fold(0i64, i64::saturating_add), - estimated_bytes_of_items_added_since_last_successful_api_response: - items_after_last_model_generated - .iter() - .map(estimate_response_item_model_visible_bytes) - .fold(0i64, i64::saturating_add), - } + pub(crate) fn estimated_tokens_after_last_model_generated_item(&self) -> i64 { + self.items_after_last_model_generated_item() + .iter() + .map(estimate_item_token_count) + .fold(0i64, i64::saturating_add) } /// This function enforces a couple of invariants on the in-memory history: @@ -532,7 +503,7 @@ static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock i64 { +fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 { match item { ResponseItem::Reasoning { encrypted_content: Some(content), diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 2295c49df3..f811c8b373 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -3,7 +3,5 @@ mod normalize; pub(crate) mod updates; pub(crate) use history::ContextManager; -pub(crate) use history::TotalTokenUsageBreakdown; -pub(crate) use history::estimate_response_item_model_visible_bytes; pub(crate) use history::is_user_turn_boundary; pub(crate) use history::truncate_function_output_payload; diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index 946779d06b..e172cd5f20 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -340,46 +340,6 @@ async fn test_get_has_changes_with_untracked_change_returns_true() { assert_eq!(get_has_changes(&repo_path).await, Some(true)); } -#[cfg(unix)] -#[tokio::test] -async fn test_get_has_changes_ignores_repo_fsmonitor_config() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - let helper_path = repo_path.join("fsmonitor-helper.sh"); - let marker_path = repo_path.join("fsmonitor-ran"); - - fs::write( - &helper_path, - format!( - "#!/bin/sh\nprintf ran > \"{}\"\n", - marker_path.to_string_lossy() - ), - ) - .expect("write fsmonitor helper"); - let mut permissions = fs::metadata(&helper_path) - .expect("read fsmonitor helper metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&helper_path, permissions).expect("mark fsmonitor helper executable"); - - Command::new("git") - .args([ - "config", - "core.fsmonitor", - helper_path.to_string_lossy().as_ref(), - ]) - .current_dir(&repo_path) - .output() - .await - .expect("configure fsmonitor helper"); - - assert_eq!(get_has_changes(&repo_path).await, Some(true)); - assert!( - !marker_path.exists(), - "metadata collection should not invoke repository fsmonitor helpers" - ); -} - #[cfg(unix)] #[tokio::test] async fn test_get_has_changes_ignores_configured_hooks_path() { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 8504690b29..fcda9a5a23 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -721,19 +721,27 @@ async fn run_review_on_session( .await .unwrap_or_default(); let guardian_permission_profile = PermissionProfile::read_only(); + let parent_turn_environments = params.parent_turn.environments.to_selections(); + let parent_turn_legacy_fallback_cwd = params + .parent_turn + .environments + .primary() + .map(|environment| environment.cwd.clone()) + .unwrap_or_else(|| params.parent_turn.config.cwd.clone()); let submit_result = run_before_review_deadline( deadline, params.external_cancel.as_ref(), Box::pin(review_session.codex.submit(Op::UserInput { items: prompt_items.items, - environments: None, final_output_json_schema: Some(params.schema.clone()), responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - #[allow(deprecated)] - cwd: Some(params.parent_turn.cwd.clone()), + environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( + parent_turn_legacy_fallback_cwd, + parent_turn_environments, + )), approval_policy: Some(AskForApproval::Never), sandbox_policy: None, permission_profile: Some(guardian_permission_profile), diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 992bf93f45..c1dfe8e639 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -7,7 +7,7 @@ use crate::plugins::test_support::write_plugins_feature_config; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsManager; -use codex_core_plugins::remote::RemotePluginScope; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::RemotePluginServiceConfig; use codex_core_plugins::remote::fetch_and_cache_global_remote_plugin_catalog; use codex_core_plugins::startup_sync::curated_plugins_repo_path; @@ -362,7 +362,7 @@ remote_plugin = true .build_and_cache_remote_installed_plugin_marketplaces( &config.plugins_config_input(), Some(&auth), - &[RemotePluginScope::Global], + &[REMOTE_GLOBAL_MARKETPLACE_NAME], /*on_effective_plugins_changed*/ None, ) .await diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 8e74599897..8c3aa8d97e 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -30,6 +30,7 @@ use tempfile::TempDir; fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThread { StoredThread { thread_id: ThreadId::new(), + extra_config: None, rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 7730a30ad7..c0f62ec664 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -122,7 +122,7 @@ async fn thread_settings_update( thread_settings: ThreadSettingsOverrides, ) -> SessionSettingsUpdate { let ThreadSettingsOverrides { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, @@ -151,7 +151,7 @@ async fn thread_settings_update( } }; SessionSettingsUpdate { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, @@ -173,6 +173,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { let state = sess.state.lock().await; state.session_configuration.thread_config_snapshot() }; + let cwd = snapshot.cwd().clone(); EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent { thread_settings: ThreadSettingsSnapshot { model: snapshot.model, @@ -182,7 +183,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { approvals_reviewer: snapshot.approvals_reviewer, permission_profile: snapshot.permission_profile, active_permission_profile: snapshot.active_permission_profile, - cwd: snapshot.cwd, + cwd, reasoning_effort: snapshot.reasoning_effort, reasoning_summary: snapshot.reasoning_summary, personality: snapshot.personality, @@ -200,7 +201,6 @@ pub(super) async fn user_input_or_turn_inner( ) { let Op::UserInput { items, - environments, final_output_json_schema, responsesapi_client_metadata, additional_context, @@ -216,7 +216,6 @@ pub(super) async fn user_input_or_turn_inner( SessionSettingsUpdate::default() }; updates.final_output_json_schema = Some(final_output_json_schema); - updates.environments = environments; let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { // new_turn_with_sub_id already emits the error event. diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 37d55256f5..bb66e8684e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -116,6 +116,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -181,7 +182,6 @@ use crate::config::PermissionProfileState; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; -use crate::context_manager::TotalTokenUsageBreakdown; use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerSource; @@ -602,11 +602,13 @@ impl Codex { approvals_reviewer: config.approvals_reviewer, permission_profile_state: session_permission_profile_state_from_config(&config)?, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new( + config.cwd.clone(), + environment_selections.to_selections(), + ), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: environment_selections.to_selections(), original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, @@ -814,7 +816,10 @@ impl Codex { pub(crate) async fn thread_environment_selections(&self) -> Vec { let state = self.session.state.lock().await; - state.session_configuration.environments.clone() + state + .session_configuration + .environment_selections() + .to_vec() } pub(crate) fn state_db(&self) -> Option { @@ -1114,7 +1119,6 @@ impl Session { self, self.next_internal_sub_id(), Op::UserInput { - environments: None, items: vec![UserInput::Text { text, text_elements: Vec::new(), @@ -1140,9 +1144,11 @@ impl Session { state.auto_compact_window_snapshot() } - pub(crate) async fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown { + pub(crate) async fn estimated_tokens_after_last_model_generated_item(&self) -> i64 { let state = self.state.lock().await; - state.history.get_total_token_usage_breakdown() + state + .history + .estimated_tokens_after_last_model_generated_item() } pub(crate) async fn total_token_usage(&self) -> Option { @@ -1414,12 +1420,12 @@ impl Session { .then(|| Self::build_effective_session_config(&state.session_configuration)); let new_config = notify_config_contributors.then(|| Self::build_effective_session_config(&updated)); - let previous_cwd = state.session_configuration.cwd.clone(); + let previous_cwd = state.session_configuration.cwd().clone(); let previous_permission_profile = state.session_configuration.permission_profile(); let updated_permission_profile = updated.permission_profile(); let permission_profile_changed = previous_permission_profile != updated_permission_profile; - let next_cwd = updated.cwd.clone(); + let next_cwd = updated.cwd().clone(); let codex_home = updated.codex_home.clone(); let session_source = updated.session_source.clone(); state.session_configuration = updated; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 92f9737310..c6d0d9767e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -12,6 +12,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use std::sync::OnceLock; use tokio::sync::Semaphore; @@ -75,11 +76,9 @@ pub(crate) struct SessionConfiguration { pub(super) permission_profile_state: PermissionProfileState, pub(super) windows_sandbox_level: WindowsSandboxLevel, - /// Absolute working directory that should be treated as the *root* of the - /// session. All relative paths supplied by the model as well as the - /// execution sandbox are resolved against this directory **instead** of - /// the process-wide current working directory. - pub(super) cwd: AbsolutePathBuf, + /// Sticky thread-level environment selections plus the legacy cwd used + /// when a turn does not select an environment. + pub(super) environments: TurnEnvironmentSelections, /// Thread-scoped runtime workspace roots for materializing symbolic /// workspace permissions at session runtime. pub(super) workspace_roots: Vec, @@ -87,8 +86,6 @@ pub(crate) struct SessionConfiguration { pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. pub(super) thread_name: Option, - /// Sticky environments for turns that do not provide a turn-local override. - pub(super) environments: Vec, // TODO(pakrym): Remove config from here pub(super) original_config_do_not_use: Arc, @@ -110,6 +107,14 @@ pub(crate) struct SessionConfiguration { } impl SessionConfiguration { + pub(super) fn cwd(&self) -> &AbsolutePathBuf { + &self.environments.legacy_fallback_cwd + } + + pub(super) fn environment_selections(&self) -> &[TurnEnvironmentSelection] { + &self.environments.environments + } + pub(crate) fn codex_home(&self) -> &AbsolutePathBuf { &self.codex_home } @@ -153,7 +158,7 @@ impl SessionConfiguration { let permission_profile = self.permission_profile(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &permission_profile, - &self.cwd, + self.cwd(), ) } @@ -176,7 +181,7 @@ impl SessionConfiguration { approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), - cwd: self.cwd.clone(), + environments: self.environments.clone(), workspace_roots: self.workspace_roots.clone(), profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, @@ -199,11 +204,11 @@ impl SessionConfiguration { let legacy_file_system_projection = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( ¤t_sandbox_policy, - &self.cwd, + self.cwd(), ¤t_file_system_sandbox_policy, ); let file_system_policy_matches_legacy = current_file_system_sandbox_policy - .is_semantically_equivalent_to(&legacy_file_system_projection, &self.cwd); + .is_semantically_equivalent_to(&legacy_file_system_projection, self.cwd()); let file_system_policy_has_rebindable_project_root_write = current_file_system_sandbox_policy .entries @@ -249,18 +254,21 @@ impl SessionConfiguration { next_configuration.windows_sandbox_level = windows_sandbox_level; } - let absolute_cwd = updates.cwd.clone().unwrap_or_else(|| self.cwd.clone()); - - let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); - next_configuration.cwd = absolute_cwd; + let current_cwd = self.cwd().clone(); + let next_environments = updates + .environments + .clone() + .unwrap_or_else(|| self.environments.clone()); + let cwd_changed = next_environments.legacy_fallback_cwd.as_path() != current_cwd.as_path(); + next_configuration.environments = next_environments; if let Some(workspace_roots) = updates.workspace_roots.clone() { next_configuration.workspace_roots = workspace_roots; - } else if cwd_changed && self.workspace_roots.contains(&self.cwd) { + } else if cwd_changed && self.workspace_roots.contains(¤t_cwd) { let mut retargeted_workspace_roots = Vec::with_capacity(next_configuration.workspace_roots.len()); for root in &self.workspace_roots { - let root = if root == &self.cwd { - next_configuration.cwd.clone() + let root = if root == ¤t_cwd { + next_configuration.cwd().clone() } else { root.clone() }; @@ -317,7 +325,7 @@ impl SessionConfiguration { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( &sandbox_policy, - &next_configuration.cwd, + next_configuration.cwd(), ¤t_file_system_sandbox_policy, ); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); @@ -340,7 +348,7 @@ impl SessionConfiguration { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( ¤t_sandbox_policy, - &next_configuration.cwd, + next_configuration.cwd(), ¤t_file_system_sandbox_policy, ); next_configuration @@ -401,7 +409,7 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { - pub(crate) cwd: Option, + pub(crate) environments: Option, pub(crate) workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, @@ -414,10 +422,6 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) reasoning_summary: Option, pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, - /// Turn-local environment override. `None` inherits the sticky thread - /// environments stored on `SessionConfiguration`; `Some([])` explicitly - /// disables environments for this turn. - pub(crate) environments: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, @@ -539,6 +543,7 @@ impl Session { Arc::clone(&thread_store), CreateThreadParams { thread_id, + extra_config: config.extra_config.clone(), forked_from_id, parent_thread_id, source: session_source, @@ -634,7 +639,7 @@ impl Session { Arc::clone(&environment_manager), Arc::clone(&plugins_manager), Arc::clone(&skills_manager), - session_configuration.environments.clone(), + session_configuration.environment_selections().to_vec(), ) .instrument(info_span!( "session_init.plugin_skill_warmup", @@ -686,7 +691,7 @@ impl Session { nickname: session_configuration.session_source.get_nickname(), agent_role: session_configuration.session_source.get_agent_role(), session_source: session_configuration.session_source.clone(), - cwd: session_configuration.cwd.to_path_buf(), + cwd: session_configuration.cwd().to_path_buf(), rollout_path: rollout_path.clone(), model: session_configuration.collaboration_mode.model().to_string(), provider_name: config.model_provider_id.clone(), @@ -790,7 +795,7 @@ impl Session { /*inc*/ 1, &[( "is_git", - if get_git_repo_root(&session_configuration.cwd).is_some() { + if get_git_repo_root(session_configuration.cwd()).is_some() { "true" } else { "false" @@ -809,7 +814,7 @@ impl Session { config.permissions.approval_policy.value(), config .permissions - .legacy_sandbox_policy(session_configuration.cwd.as_path()), + .legacy_sandbox_policy(session_configuration.cwd().as_path()), mcp_servers.keys().map(String::as_str).collect(), ); @@ -844,7 +849,7 @@ impl Session { ShellSnapshot::start_snapshotting( config.codex_home.clone(), thread_id, - session_configuration.cwd.clone(), + session_configuration.cwd().clone(), &mut default_shell, session_telemetry.clone(), state_db_ctx.clone(), @@ -1089,7 +1094,7 @@ impl Session { approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), active_permission_profile: session_configuration.active_permission_profile(), - cwd: session_configuration.cwd.clone(), + cwd: session_configuration.cwd().clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), initial_messages, network_proxy: session_network_proxy.filter(|_| { @@ -1135,7 +1140,7 @@ impl Session { } let turn_environment = crate::environment_selection::resolve_environment_selections( sess.services.environment_manager.as_ref(), - &session_configuration.environments, + session_configuration.environment_selections(), ) .map_err(|err| { CodexErr::InvalidRequest(err.to_string().replace( @@ -1152,7 +1157,7 @@ impl Session { ), None => McpRuntimeContext::new( Arc::clone(&sess.services.environment_manager), - session_configuration.cwd.to_path_buf(), + session_configuration.cwd().to_path_buf(), ), }; let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4d914ff985..ef3f6dbfba 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -22,6 +22,7 @@ use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::loader::project_trust_key; use codex_config::types::ToolSuggestDisabledTool; +use core_test_support::test_codex::local_selections; use codex_features::Feature; use codex_login::CodexAuth; @@ -53,6 +54,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; @@ -142,6 +144,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; use core_test_support::tracing::install_test_tracing; @@ -2392,7 +2395,8 @@ async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow: ); let mut session_configuration = make_session_configuration_for_tests().await; - session_configuration.cwd = config.cwd.clone(); + session_configuration.environments = + TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()); session_configuration.workspace_roots = config.workspace_roots.clone(); session_configuration.permission_profile_state = session_permission_profile_state; @@ -2404,8 +2408,8 @@ async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow: ..Default::default() })?; let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); Ok(()) } @@ -2437,7 +2441,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "fork seed".into(), text_elements: Vec::new(), @@ -2483,7 +2486,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< forked .thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -3157,11 +3159,10 @@ async fn set_rate_limits_retains_previous_credits() { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3265,11 +3266,10 @@ async fn set_rate_limits_updates_plan_type_when_present() { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3526,6 +3526,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { Arc::clone(&session.services.thread_store), CreateThreadParams { thread_id: session.thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, @@ -3797,11 +3798,10 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3920,7 +3920,8 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd let project_root = project_root.abs(); let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd.abs(); + session_configuration.environments = + TurnEnvironmentSelections::new(original_cwd.abs(), Vec::new()); let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -3954,7 +3955,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root), + environments: Some(TurnEnvironmentSelections::new(project_root, Vec::new())), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -3969,7 +3970,8 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd async fn session_configuration_apply_permission_profile_preserves_existing_deny_read_entries() { let mut session_configuration = make_session_configuration_for_tests().await; let cwd = tempfile::tempdir().expect("create temp dir"); - session_configuration.cwd = cwd.path().abs(); + session_configuration.environments = + TurnEnvironmentSelections::new(cwd.path().abs(), Vec::new()); let workspace_policy = SandboxPolicy::new_workspace_write_policy(); let deny_entry = FileSystemSandboxEntry { @@ -3981,7 +3983,7 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ let mut existing_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &workspace_policy, - session_configuration.cwd.as_path(), + session_configuration.cwd().as_path(), ); existing_file_system_policy.glob_scan_max_depth = Some(2); existing_file_system_policy.entries.push(deny_entry.clone()); @@ -3997,7 +3999,7 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ let requested_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &workspace_policy, - session_configuration.cwd.as_path(), + session_configuration.cwd().as_path(), ); let permission_profile = codex_protocol::models::PermissionProfile::from_runtime_permissions( &requested_file_system_policy, @@ -4024,7 +4026,8 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ async fn session_configuration_apply_permission_profile_accepts_direct_write_roots() { let mut session_configuration = make_session_configuration_for_tests().await; let cwd = tempfile::tempdir().expect("create cwd"); - session_configuration.cwd = cwd.path().abs(); + session_configuration.environments = + TurnEnvironmentSelections::new(cwd.path().abs(), Vec::new()); let external_write_dir = tempfile::tempdir().expect("create external write root"); let external_write_path = AbsolutePathBuf::from_absolute_path( codex_utils_absolute_path::canonicalize_preserving_symlinks(external_write_dir.path()) @@ -4100,8 +4103,8 @@ async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspa .expect("permission profile update should succeed"); let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); assert_eq!( updated.active_permission_profile(), Some(ActivePermissionProfile::new("dev")) @@ -4118,7 +4121,8 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up let old_root = old_root.path().abs(); let new_root = new_root.path().abs(); let extra_root = extra_root.path().abs(); - session_configuration.cwd = old_root.clone(); + session_configuration.environments = + TurnEnvironmentSelections::new(old_root.clone(), Vec::new()); session_configuration.workspace_roots = vec![old_root.clone(), extra_root.clone()]; let file_system_sandbox_policy = @@ -4138,7 +4142,7 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(new_root.clone()), + environments: Some(TurnEnvironmentSelections::new(new_root.clone(), Vec::new())), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4148,9 +4152,9 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up vec![new_root.clone(), extra_root.clone()] ); let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); - assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd.as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); + assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd().as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); } #[tokio::test] @@ -4353,8 +4357,9 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let workspace = tempfile::tempdir().expect("create temp dir"); let original_cwd = workspace.path().join("repo-a").abs(); let project_root = workspace.path().join("repo-b").abs(); - session_configuration.cwd = original_cwd.clone(); - session_configuration.workspace_roots = vec![session_configuration.cwd.clone()]; + session_configuration.environments = + TurnEnvironmentSelections::new(original_cwd.clone(), Vec::new()); + session_configuration.workspace_roots = vec![session_configuration.cwd().clone()]; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -4363,7 +4368,7 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda }; let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &sandbox_policy, - &session_configuration.cwd, + session_configuration.cwd(), ); session_configuration .set_permission_profile_for_tests( @@ -4377,7 +4382,10 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.clone()), + environments: Some(TurnEnvironmentSelections::new( + project_root.clone(), + Vec::new(), + )), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4386,13 +4394,13 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda assert!( updated .file_system_sandbox_policy() - .can_write_path_with_cwd(project_root.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(project_root.as_path(), updated.cwd().as_path()), "cwd-only update should keep the new cwd writable" ); assert!( !updated .file_system_sandbox_policy() - .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()), "cwd-only update should not keep the old implicit cwd writable" ); } @@ -4408,7 +4416,8 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up let original_cwd = original_cwd.abs(); let next_cwd = next_cwd.abs(); - session_configuration.cwd = original_cwd.clone(); + session_configuration.environments = + TurnEnvironmentSelections::new(original_cwd.clone(), Vec::new()); let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -4435,7 +4444,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(next_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new(next_cwd.clone(), Vec::new())), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4447,13 +4456,13 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up assert!( updated .file_system_sandbox_policy() - .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()), "absolute grant to the old cwd must remain writable" ); assert!( !updated .file_system_sandbox_policy() - .can_write_path_with_cwd(next_cwd.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(next_cwd.as_path(), updated.cwd().as_path()), "cwd-only update must not reinterpret an absolute old-cwd grant as :workspace_roots" ); } @@ -4463,11 +4472,21 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let (session, turn_context) = make_session_and_context().await; #[allow(deprecated)] let updated_cwd = turn_context.cwd.join("project"); + let current_environments = { + let state = session.state.lock().await; + state + .session_configuration + .environment_selections() + .to_vec() + }; std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); session .update_settings(SessionSettingsUpdate { - cwd: Some(updated_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new( + updated_cwd.clone(), + current_environments, + )), ..Default::default() }) .await @@ -4475,7 +4494,7 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let session_cwd = { let state = session.state.lock().await; - state.session_configuration.cwd.clone() + state.session_configuration.cwd().clone() }; let config = session.get_config().await; let next_turn = session.new_default_turn().await; @@ -4495,55 +4514,65 @@ async fn relative_cwd_update_without_environments_resolves_under_session_cwd() { let (session, _turn_context) = make_session_and_context().await; let original_cwd = { let mut state = session.state.lock().await; - state.session_configuration.environments = Vec::new(); - state.session_configuration.cwd.clone() + state.session_configuration.environments.environments = Vec::new(); + state.session_configuration.cwd().clone() }; let updated_cwd = original_cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); session .update_settings(SessionSettingsUpdate { - cwd: Some(updated_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new( + updated_cwd.clone(), + Vec::new(), + )), ..Default::default() }) .await .expect("cwd update should succeed"); let state = session.state.lock().await; - assert_eq!(state.session_configuration.cwd, updated_cwd); - assert!(state.session_configuration.environments.is_empty()); + assert_eq!(state.session_configuration.cwd(), &updated_cwd); + assert!( + state + .session_configuration + .environment_selections() + .is_empty() + ); } #[tokio::test] -async fn cwd_update_does_not_rewrite_sticky_environment_cwd() { +async fn cwd_update_rewrites_sticky_environment_cwd() { let (session, _turn_context) = make_session_and_context().await; - let (original_cwd, environment_cwd) = { + let (original_cwd, environment_cwd, environments) = { let mut state = session.state.lock().await; - let original_cwd = state.session_configuration.cwd.clone(); + let original_cwd = state.session_configuration.cwd().clone(); let environment_cwd = original_cwd.join("environment"); - state.session_configuration.environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: environment_cwd.clone(), - }]; - (original_cwd, environment_cwd) + let environments = vec![local(environment_cwd.clone())]; + state.session_configuration.environments.environments = environments.clone(); + (original_cwd, environment_cwd, environments) }; let updated_cwd = original_cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); session .update_settings(SessionSettingsUpdate { - cwd: Some(updated_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new( + updated_cwd.clone(), + environments, + )), ..Default::default() }) .await .expect("cwd update should succeed"); let state = session.state.lock().await; - assert_eq!(state.session_configuration.cwd, updated_cwd); + assert_eq!(state.session_configuration.cwd(), &updated_cwd); assert_eq!( - state.session_configuration.environments[0].cwd, - environment_cwd + state.session_configuration.environment_selections()[0].cwd, + updated_cwd ); + assert_ne!(environment_cwd, updated_cwd); } #[tokio::test] @@ -4551,7 +4580,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; let absolute_cwd = { let state = session.state.lock().await; - state.session_configuration.cwd.join("absolute-turn") + state.session_configuration.cwd().join("absolute-turn") }; std::fs::create_dir_all(absolute_cwd.as_path()).expect("create absolute turn dir"); @@ -4559,11 +4588,10 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - cwd: Some(absolute_cwd.clone()), - environments: Some(vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: absolute_cwd.clone(), - }]), + environments: Some(TurnEnvironmentSelections::new( + absolute_cwd.clone(), + vec![local(absolute_cwd.clone())], + )), ..Default::default() }, ) @@ -4622,11 +4650,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -4712,10 +4739,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4733,11 +4757,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -4751,7 +4774,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { user_shell_override: None, }; let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); + Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -4863,7 +4886,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) .await, ); - let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); + let turn_environments = turn_environments_for_tests(&environment, session_configuration.cwd()); let turn_context = Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -4880,7 +4903,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &models_manager, /*network*/ None, turn_environments, - session_configuration.cwd.clone(), + session_configuration.cwd().clone(), "turn_id".to_string(), skills_outcome, ); @@ -4948,10 +4971,7 @@ async fn make_session_with_config_and_rx( developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4969,11 +4989,10 @@ async fn make_session_with_config_and_rx( approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -5053,10 +5072,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -5074,11 +5090,10 @@ async fn make_session_with_history_source_and_agent_control_and_rx( approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -5937,7 +5952,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { fn op_kind_for_input_and_context_ops() { assert_eq!( Op::UserInput { - environments: None, items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -5969,12 +5983,11 @@ async fn user_turn_updates_approvals_reviewer() { text: "hello".to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.clone()), + environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), sandbox_policy: Some(config.legacy_sandbox_policy()), @@ -6013,10 +6026,10 @@ async fn turn_environments_set_primary_environment() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: selected_cwd.clone(), - }]), + environments: Some(TurnEnvironmentSelections::new( + selected_cwd.clone(), + vec![local(selected_cwd.clone())], + )), ..Default::default() }, ) @@ -6041,7 +6054,7 @@ async fn turn_environments_set_primary_environment() { } #[tokio::test] -async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { +async fn default_turn_does_not_overlay_legacy_fallback_cwd_onto_stored_thread_environments() { let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; let session_cwd = session.get_config().await.cwd.clone(); let selected_cwd = @@ -6049,10 +6062,7 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { { let mut state = session.state.lock().await; - state.session_configuration.environments = vec![TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: selected_cwd.clone(), - }]; + state.session_configuration.environments.environments = vec![local(selected_cwd.clone())]; } let turn_context = session.new_default_turn().await; @@ -6069,8 +6079,8 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { )); #[allow(deprecated)] let turn_cwd = turn_context.cwd.clone(); - assert_eq!(turn_cwd, session_cwd); - assert_eq!(turn_context.config.cwd, session_cwd); + assert_eq!(turn_cwd, selected_cwd); + assert_eq!(turn_context.config.cwd, selected_cwd); } #[tokio::test] @@ -6080,7 +6090,7 @@ async fn default_turn_honors_empty_stored_thread_environments() { { let mut state = session.state.lock().await; - state.session_configuration.environments = Vec::new(); + state.session_configuration.environments.environments = Vec::new(); } let turn_context = session.new_default_turn().await; @@ -6143,7 +6153,10 @@ async fn empty_turn_environments_clear_primary_environment() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![]), + environments: Some(TurnEnvironmentSelections::new( + session.get_config().await.cwd.clone(), + vec![], + )), ..Default::default() }, ) @@ -6170,10 +6183,13 @@ async fn unknown_turn_environment_returns_error() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![TurnEnvironmentSelection { - environment_id: "missing".to_string(), - cwd: original_configuration.cwd.clone(), - }]), + environments: Some(TurnEnvironmentSelections::new( + original_configuration.cwd().clone(), + vec![TurnEnvironmentSelection { + environment_id: "missing".to_string(), + cwd: original_configuration.cwd().clone(), + }], + )), ..Default::default() }, ) @@ -6186,10 +6202,10 @@ async fn unknown_turn_environment_returns_error() { }; assert!(matches!(err, CodexErr::InvalidRequest(_))); assert!(err.to_string().contains("missing")); - assert_eq!(current_configuration.cwd, original_configuration.cwd); + assert_eq!(current_configuration.cwd(), original_configuration.cwd()); assert_eq!( - current_configuration.environments, - original_configuration.environments + current_configuration.environment_selections(), + original_configuration.environment_selections() ); } @@ -6205,16 +6221,13 @@ async fn duplicate_turn_environment_returns_error_without_mutating_session() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![ - TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: original_configuration.cwd.clone(), - }, - TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: original_configuration.cwd.join("second"), - }, - ]), + environments: Some(TurnEnvironmentSelections::new( + original_configuration.cwd().clone(), + vec![ + local(original_configuration.cwd().clone()), + local(original_configuration.cwd().join("second")), + ], + )), ..Default::default() }, ) @@ -6227,10 +6240,10 @@ async fn duplicate_turn_environment_returns_error_without_mutating_session() { }; assert!(matches!(err, CodexErr::InvalidRequest(_))); assert!(err.to_string().contains("duplicate")); - assert_eq!(current_configuration.cwd, original_configuration.cwd); + assert_eq!(current_configuration.cwd(), original_configuration.cwd()); assert_eq!( - current_configuration.environments, - original_configuration.environments + current_configuration.environment_selections(), + original_configuration.environment_selections() ); } @@ -6348,6 +6361,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { Arc::clone(&thread_store), CreateThreadParams { thread_id: session.thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, @@ -6789,10 +6803,7 @@ where developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -6810,11 +6821,10 @@ where approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -6828,7 +6838,7 @@ where user_shell_override: None, }; let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); + Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -6940,7 +6950,7 @@ where .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) .await, ); - let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); + let turn_environments = turn_environments_for_tests(&environment, session_configuration.cwd()); let turn_context = Arc::new(Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -6957,7 +6967,7 @@ where &models_manager, /*network*/ None, turn_environments, - session_configuration.cwd.clone(), + session_configuration.cwd().clone(), "turn_id".to_string(), skills_outcome, )); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 9d4e7d1e82..959e28834a 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1431,7 +1431,8 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::CollabCloseBegin(_) | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeBegin(_) - | EventMsg::CollabResumeEnd(_) => None, + | EventMsg::CollabResumeEnd(_) + | EventMsg::SubAgentActivity(_) => None, } } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 5f52912d59..be6e316a00 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -441,7 +441,7 @@ impl Session { session_configuration: &SessionConfiguration, ) -> Config { let mut config = - Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone()); + Self::build_per_turn_config(session_configuration, session_configuration.cwd().clone()); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); config.workspace_roots = session_configuration.workspace_roots.clone(); @@ -587,19 +587,9 @@ impl Session { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { Ok(next) => { - let mut effective_environments = updates - .environments - .clone() - .unwrap_or_else(|| next.environments.clone()); - if updates.environments.is_none() { - Self::overlay_runtime_cwd_on_primary_environment( - &mut effective_environments, - &next.cwd, - ); - } let turn_environments = - self.resolve_turn_environments(&effective_environments)?; - let previous_cwd = state.session_configuration.cwd.clone(); + self.resolve_turn_environments(next.environment_selections())?; + let previous_cwd = state.session_configuration.cwd().clone(); let previous_permission_profile = state.session_configuration.permission_profile(); let next_permission_profile = next.permission_profile(); @@ -656,7 +646,7 @@ impl Session { self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); self.maybe_refresh_shell_snapshot_for_cwd( &previous_cwd, - &session_configuration.cwd, + session_configuration.cwd(), &codex_home, &session_source, ); @@ -731,7 +721,7 @@ impl Session { let cwd = primary_turn_environment .as_ref() .map(|turn_environment| turn_environment.cwd.clone()) - .unwrap_or_else(|| session_configuration.cwd.clone()); + .unwrap_or_else(|| session_configuration.cwd().clone()); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { let mcp_connection_manager = self.services.mcp_connection_manager.read().await; @@ -871,29 +861,14 @@ impl Session { let state = self.state.lock().await; state.session_configuration.clone() }; - let mut effective_environments = session_configuration.environments.clone(); - Self::overlay_runtime_cwd_on_primary_environment( - &mut effective_environments, - &session_configuration.cwd, - ); - let turn_environments = match self.resolve_turn_environments(&effective_environments) { - Ok(turn_environments) => turn_environments, - Err(err) => { - warn!("failed to resolve stored session environments: {err}"); - ResolvedTurnEnvironments::default() - } - }; + let turn_environments = + match self.resolve_turn_environments(session_configuration.environment_selections()) { + Ok(turn_environments) => turn_environments, + Err(err) => { + warn!("failed to resolve stored session environments: {err}"); + ResolvedTurnEnvironments::default() + } + }; (session_configuration, turn_environments) } - - fn overlay_runtime_cwd_on_primary_environment( - environments: &mut [TurnEnvironmentSelection], - runtime_cwd: &AbsolutePathBuf, - ) { - if let Some(turn_environment) = environments.first_mut() - && turn_environment.cwd != *runtime_cwd - { - turn_environment.cwd = runtime_cwd.clone(); - } - } } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 34ec682110..116473258e 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -21,7 +21,6 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; -use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::responses::mount_models_once; @@ -343,99 +342,6 @@ async fn start_thread_rejects_explicit_local_environment_when_default_provider_i assert!(manager.list_thread_ids().await.is_empty()); } -#[tokio::test] -async fn start_thread_uses_all_default_environments_from_codex_home() { - let temp_dir = tempdir().expect("tempdir"); - let mut config = test_config().await; - config.codex_home = temp_dir.path().join("codex-home").abs(); - config.cwd = config.codex_home.abs(); - std::fs::create_dir_all(&config.codex_home).expect("create codex home"); - std::fs::write( - config.codex_home.join("environments.toml"), - r#" -default = "dev" - -[[environments]] -id = "dev" -program = "ssh" -args = ["dev", "cd /tmp && true"] -"#, - ) - .expect("write environments.toml"); - - let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe path"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths"); - let environment_manager = Arc::new( - codex_exec_server::EnvironmentManager::from_codex_home( - config.codex_home.clone(), - Some(runtime_paths), - ) - .await - .expect("environment manager"), - ); - assert_eq!( - environment_manager.default_environment_ids(), - vec!["dev".to_string(), "local".to_string()] - ); - - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.to_path_buf(), - environment_manager, - ); - - let thread = manager - .start_thread(config) - .await - .expect("thread should start"); - - let prompt_items = crate::prompt_debug::build_prompt_input_from_session( - thread.thread.codex.session.as_ref(), - Vec::::new(), - ) - .await - .expect("prompt input"); - let environment_context = prompt_items - .iter() - .filter_map(|item| match item { - ResponseItem::Message { content, .. } => Some(content), - _ => None, - }) - .flatten() - .find_map(|content| match content { - ContentItem::InputText { text } if text.contains("") => { - Some(text.as_str()) - } - _ => None, - }) - .expect("environment context prompt item"); - assert!(environment_context.contains("")); - let cwd = thread.session_configured.cwd.display().to_string(); - let dev_entry = format!( - r#" - {cwd} - "# - ); - let local_entry = format!( - r#" - {cwd} - "# - ); - let dev_position = environment_context - .find(&dev_entry) - .expect("dev environment entry"); - let local_position = environment_context - .find(&local_entry) - .expect("local environment entry"); - assert!(dev_position < local_position); - assert!(!environment_context.contains("\n ")); - assert!(!environment_context.contains("\n ")); -} - #[tokio::test] async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { let temp_dir = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 8455b13036..8f0d9d56b9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -741,6 +741,7 @@ fn spawn_agent_tool_description_v2( You are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`. The spawned agent will have the same tools as you and the ability to spawn its own subagents. {inherited_model_guidance} +Only call this tool for a concrete, bounded subtask that can run independently alongside useful local work; otherwise continue locally. It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes. The new agent's canonical task name will be provided to it along with the message. {concurrency_guidance}"# 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 ac78113a30..f55d9c0876 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2676,7 +2676,6 @@ async fn send_input_accepts_structured_items() { .expect("send_input should succeed"); let expected = Op::UserInput { - environments: None, items: vec![ UserInput::Mention { name: "drive".to_string(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 6b5bd9f241..cae3497a13 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -14,15 +14,11 @@ use crate::tools::registry::ToolExecutor; use codex_protocol::AgentPath; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::SubAgentActivityEvent; +use codex_protocol::protocol::SubAgentActivityKind; use codex_protocol::user_input::UserInput; use codex_tools::ToolName; use serde::Deserialize; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs index 907367348b..159c49127a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs @@ -59,18 +59,9 @@ async fn handle_interrupt_agent( .to_string(), )); } - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.thread_id, - receiver_thread_id: agent_id, - } - .into(), - ) - .await; + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) + })?; let status = session.services.agent_control.get_status(agent_id).await; let result = match session .services @@ -81,22 +72,20 @@ async fn handle_interrupt_agent( Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => Ok(()), Err(err) => Err(collab_agent_error(agent_id, err)), }; + result?; session .send_event( &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.thread_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), + SubAgentActivityEvent { + event_id: call_id, + occurred_at_ms: now_unix_timestamp_ms(), + agent_thread_id: agent_id, + agent_path: receiver_agent_path, + kind: SubAgentActivityKind::Interrupted, } .into(), ) .await; - result?; Ok(InterruptAgentResult { previous_status: status, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 07fd9d70ee..7fdb80e8bc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -69,7 +69,6 @@ pub(crate) async fn handle_message_string_tool( call_id, .. } = invocation; - let prompt = String::new(); let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; let receiver_agent = session .services @@ -96,52 +95,32 @@ pub(crate) async fn handle_message_string_tool( .ensure_v2_agent_loaded(resume_config, receiver_thread_id) .await .map_err(|err| collab_agent_error(receiver_thread_id, err))?; - session - .send_event( - &turn, - CollabAgentInteractionBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.thread_id, - receiver_thread_id, - prompt: prompt.clone(), - } - .into(), - ) - .await; let author = turn .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let communication = communication_from_tool_message(author, receiver_agent_path, message); + let communication = + communication_from_tool_message(author, receiver_agent_path.clone(), message); let result = session .services .agent_control .send_inter_agent_communication(receiver_thread_id, mode.apply(communication)) .await .map_err(|err| collab_agent_error(receiver_thread_id, err)); - let status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; + result?; session .send_event( &turn, - CollabAgentInteractionEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.thread_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - prompt, - status, + SubAgentActivityEvent { + event_id: call_id, + occurred_at_ms: now_unix_timestamp_ms(), + agent_thread_id: receiver_thread_id, + agent_path: receiver_agent_path, + kind: SubAgentActivityKind::Interacted, } .into(), ) .await; - result?; Ok(FunctionToolOutput::from_text(String::new(), Some(true))) } 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 b5b7d210f5..fc97df05dc 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 @@ -61,24 +61,8 @@ async fn handle_spawn_agent( let message = args.message.clone(); let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = String::new(); - let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.thread_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), - } - .into(), - ) - .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; if let Some(service_tier) = args.service_tier.as_ref() { @@ -119,11 +103,16 @@ async fn handle_spawn_agent( role_name, Some(args.task_name.clone()), )?; - let result = Box::pin( + let new_agent_path = spawn_source.get_agent_path().ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawned agent is missing a canonical task name".to_string(), + ) + })?; + let spawned_agent = Box::pin( session.services.agent_control.spawn_agent_with_metadata( config, - match (spawn_source.get_agent_path(), initial_operation) { - (Some(recipient), Op::UserInput { items, .. }) + match initial_operation { + Op::UserInput { items, .. } if items .iter() .all(|item| matches!(item, UserInput::Text { .. })) => @@ -132,10 +121,11 @@ async fn handle_spawn_agent( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let communication = communication_from_tool_message(author, recipient, message); + let communication = + communication_from_tool_message(author, new_agent_path.clone(), message); Op::InterAgentCommunication { communication } } - (_, initial_operation) => initial_operation, + initial_operation => initial_operation, }, Some(spawn_source), SpawnAgentOptions { @@ -147,78 +137,37 @@ async fn handle_spawn_agent( ), ) .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await - } - None => None, - }; - let (new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), - ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), - }; - let effective_model = agent_snapshot + .map_err(collab_spawn_error)?; + let new_thread_id = spawned_agent.thread_id; + let agent_snapshot = session + .services + .agent_control + .get_agent_config_snapshot(new_thread_id) + .await; + let nickname = agent_snapshot .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort.clone()) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); + .and_then(|snapshot| snapshot.session_source.get_nickname()) + .or(spawned_agent.metadata.agent_nickname); session .send_event( &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.thread_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, + SubAgentActivityEvent { + event_id: call_id, + occurred_at_ms: now_unix_timestamp_ms(), + agent_thread_id: new_thread_id, + agent_path: new_agent_path.clone(), + kind: SubAgentActivityKind::Started, } .into(), ) .await; - let _ = result?; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", /*inc*/ 1, &[("role", role_tag)], ); - let task_name = new_agent_path.ok_or_else(|| { - FunctionCallError::RespondToModel( - "spawned agent is missing a canonical task name".to_string(), - ) - })?; + let task_name = String::from(new_agent_path); let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; if hide_agent_metadata { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 1e43135900..62f422ba1c 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -40,6 +40,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; @@ -70,6 +71,17 @@ const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL" static REMOTE_TEST_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0); const SUBMIT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); +pub fn local(cwd: AbsolutePathBuf) -> TurnEnvironmentSelection { + TurnEnvironmentSelection { + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + cwd, + } +} + +pub fn local_selections(cwd: AbsolutePathBuf) -> TurnEnvironmentSelections { + TurnEnvironmentSelections::new(cwd.clone(), vec![local(cwd)]) +} + #[derive(Debug)] pub struct TestEnv { environment: codex_exec_server::Environment, @@ -789,18 +801,20 @@ impl TestCodex { let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, self.config.cwd.as_path()); let session_model = self.session_configured.model.clone(); + let turn_environment_selections = environments.map(|environments| { + TurnEnvironmentSelections::new(self.config.cwd.clone(), environments) + }); self.codex .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], - environments, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(self.config.cwd.clone()), + environments: turn_environment_selections, approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 82bb8c8798..d44b4e5a23 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -46,7 +46,6 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { // Kick off a turn that triggers the function call. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start sleep".into(), text_elements: Vec::new(), @@ -104,7 +103,6 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start history recording".into(), text_elements: Vec::new(), @@ -126,7 +124,6 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), @@ -210,7 +207,6 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start interrupt marker".into(), text_elements: Vec::new(), @@ -232,7 +228,6 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/additional_context.rs b/codex-rs/core/tests/suite/additional_context.rs index f9bfdb966b..6812be95e4 100644 --- a/codex-rs/core/tests/suite/additional_context.rs +++ b/codex-rs/core/tests/suite/additional_context.rs @@ -37,7 +37,6 @@ async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "inspect the active tab".to_string(), text_elements: Vec::new(), @@ -136,7 +135,6 @@ async fn external_context_like_user_text_remains_a_user_message_item() -> Result test.codex .submit(Op::UserInput { - environments: None, items: vec![user_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -182,7 +180,6 @@ async fn additional_context_trust_controls_message_role() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "inspect context".to_string(), text_elements: Vec::new(), @@ -263,7 +260,6 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -281,7 +277,6 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -343,7 +338,6 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -376,7 +370,6 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -409,7 +402,6 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "third turn".to_string(), text_elements: Vec::new(), @@ -506,7 +498,6 @@ async fn additional_context_values_are_truncated_before_model_input() -> Result< test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "summarize context".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index db301e39b4..a9ebadcebc 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -7,6 +7,7 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; use core_test_support::responses::ev_shell_command_call; use core_test_support::test_codex::ApplyPatchModelOutput; +use core_test_support::test_codex::local_selections; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -52,6 +53,7 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -100,12 +102,11 @@ async fn submit_without_wait_with_turn_permissions( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(harness.cwd_abs()), + environments: Some(local_selections(harness.cwd_abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -1631,27 +1632,27 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + let environments = vec![ + local(shared_cwd.clone()), + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + ]; test.codex .submit(Op::UserInput { items: vec![UserInput::Text { text: "apply matching patches to local and remote environments".into(), text_elements: Vec::new(), }], - environments: Some(vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: shared_cwd.clone(), - }, - TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: shared_cwd.clone(), - }, - ]), final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( + test.config.cwd.clone(), + environments, + )), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 865aabb46c..927b1f18f9 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -39,6 +39,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -658,12 +659,11 @@ async fn submit_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2619,12 +2619,11 @@ async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork( text: "run nested env zsh script through python".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2764,12 +2763,11 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { text: "run allowed touch under zsh fork".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -3356,12 +3354,11 @@ allow_local_binding = true text: "deny-read network retry".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(turn_sandbox_policy), diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 5de8f421b9..5fe1cbc2f2 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -34,6 +34,7 @@ use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -160,12 +161,11 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re text: "run the Guardian model override check".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2fcb73e25a..9a6f104d0c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -66,6 +66,7 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_failed; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use dunce::canonicalize as normalize_path; @@ -386,7 +387,6 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // 2) Submit new input; the request body must include the prior items, then initial context, then new user input. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -755,7 +755,6 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -968,7 +967,6 @@ async fn includes_base_instructions_override_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1026,7 +1024,6 @@ async fn chatgpt_auth_sends_correct_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1151,7 +1148,6 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1191,7 +1187,6 @@ async fn includes_user_instructions_message_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1280,7 +1275,6 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1344,7 +1338,6 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1404,7 +1397,6 @@ async fn omits_apps_guidance_when_configured_off() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1447,7 +1439,6 @@ async fn omits_environment_context_when_configured_off() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1505,7 +1496,6 @@ async fn skills_append_to_developer_message() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1589,7 +1579,6 @@ async fn skills_use_aliases_in_developer_message_under_budget_pressure() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1651,7 +1640,6 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1694,7 +1682,6 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1738,7 +1725,6 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1794,12 +1780,11 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.clone()), + environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some( @@ -1847,7 +1832,6 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1904,7 +1888,6 @@ async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1972,12 +1955,11 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.clone()), + environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some(ReasoningSummary::Concise), @@ -2029,7 +2011,6 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2089,7 +2070,6 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2129,7 +2109,6 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2178,7 +2157,6 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2226,7 +2204,6 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2279,7 +2256,6 @@ async fn includes_developer_instructions_message_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2577,7 +2553,6 @@ async fn token_count_includes_rate_limits_snapshot() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2719,7 +2694,6 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { let submission_id = codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2797,7 +2771,6 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "seed turn".into(), text_elements: Vec::new(), @@ -2813,7 +2786,6 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "trigger context window".into(), text_elements: Vec::new(), @@ -2899,7 +2871,6 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res .await?; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "trigger incomplete".into(), text_elements: Vec::new(), @@ -3011,7 +2982,6 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -3101,7 +3071,6 @@ async fn env_var_overrides_loaded_auth() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -3159,7 +3128,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 1: user sends U1; wait for completion. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "U1".into(), text_elements: Vec::new(), @@ -3176,7 +3144,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 2: user sends U2; wait for completion. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "U2".into(), text_elements: Vec::new(), @@ -3193,7 +3160,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 3: user sends U3; wait for completion. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "U3".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 3e1ed23261..4dc674ec30 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1231,7 +1231,6 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { let submission_id = test .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1322,7 +1321,6 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { let submission_id = test .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index f5f17a09bd..e1b02292ef 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3199,12 +3199,14 @@ text( text: "use exec to inspect and call hidden tools".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( + cwd, + Vec::new(), + )), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 0d4f4cf6f8..d9d511edbd 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -13,6 +13,7 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -72,7 +73,6 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -127,7 +127,6 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -169,12 +168,11 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -220,12 +218,11 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -276,7 +273,6 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -329,12 +325,11 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -390,7 +385,6 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -414,7 +408,6 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -467,7 +460,6 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -491,7 +483,6 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -546,7 +537,6 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -573,7 +563,6 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -629,7 +618,6 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -656,7 +644,6 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -715,7 +702,6 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -732,7 +718,6 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -785,7 +770,6 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5fe90bf7a7..ac497aff3b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -31,6 +31,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::test_path_buf; @@ -96,12 +97,11 @@ fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: S text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -404,7 +404,6 @@ async fn summarize_context_three_requests_and_instructions() { // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), @@ -430,7 +429,6 @@ async fn summarize_context_three_requests_and_instructions() { // 3) Next user input – third hit; history should include only the summary. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: THIRD_USER_MSG.into(), text_elements: Vec::new(), @@ -606,7 +604,6 @@ async fn manual_pre_compact_block_decision_does_not_block_compaction() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello before blocked compact".to_string(), text_elements: Vec::new(), @@ -680,7 +677,6 @@ async fn compact_hooks_respect_matchers_and_post_runs_after_compaction() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello before matched compact".to_string(), text_elements: Vec::new(), @@ -751,7 +747,6 @@ async fn manual_compact_uses_custom_prompt() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -899,7 +894,6 @@ async fn manual_compact_emits_context_compaction_items() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "manual compact".into(), text_elements: Vec::new(), @@ -1066,7 +1060,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // Start the conversation with the user message codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user_message.into(), text_elements: Vec::new(), @@ -1539,7 +1532,6 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1556,7 +1548,6 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1573,7 +1564,6 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1745,7 +1735,6 @@ async fn auto_compact_emits_context_compaction_items() { for user in [FIRST_AUTO_MSG, SECOND_AUTO_MSG, POST_AUTO_USER_MSG] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -1827,7 +1816,6 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1843,7 +1831,6 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1859,7 +1846,6 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -2388,7 +2374,6 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2404,7 +2389,6 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2420,7 +2404,6 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -2510,7 +2493,6 @@ async fn manual_compact_retries_after_context_window_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2615,7 +2597,6 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2711,7 +2692,6 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2730,7 +2710,6 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2749,7 +2728,6 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: final_user_message.into(), text_elements: Vec::new(), @@ -2961,7 +2939,6 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3067,7 +3044,6 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FUNCTION_CALL_LIMIT_MSG.into(), text_elements: Vec::new(), @@ -3499,7 +3475,6 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3619,7 +3594,6 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { for user in [first_user, second_user, third_user] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3682,7 +3656,6 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess for user in ["USER_ONE", "USER_TWO"] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -3699,7 +3672,9 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), + environments: Some(local_selections( + test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs(), + )), ..Default::default() }, ) @@ -3709,7 +3684,6 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess .to_string(); codex .submit(Op::UserInput { - environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -3905,7 +3879,6 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3921,7 +3894,6 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3995,7 +3967,6 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 12fd570c56..73a500f04d 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,5 +1,6 @@ #![allow(clippy::expect_used)] +use core_test_support::test_codex::local_selections; use std::fs; use anyhow::Result; @@ -324,7 +325,6 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -342,7 +342,6 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -585,7 +584,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "TURN_ONE_USER".to_string(), text_elements: Vec::new(), @@ -600,7 +598,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![ UserInput::Text { text: "TURN_TWO_PREFIX".to_string(), @@ -621,7 +618,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "TURN_THREE_TOOL_USER".to_string(), text_elements: Vec::new(), @@ -636,7 +632,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![ UserInput::Image { image_url, @@ -657,7 +652,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "TURN_FIVE_USER".to_string(), text_elements: Vec::new(), @@ -826,7 +820,6 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -844,7 +837,6 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -976,7 +968,6 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -994,7 +985,6 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -1081,7 +1071,6 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -1099,7 +1088,6 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -1187,7 +1175,6 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -1260,7 +1247,6 @@ async fn remote_compact_runs_automatically() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -1398,7 +1384,6 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -1413,7 +1398,6 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1528,7 +1512,6 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -1543,7 +1526,6 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1656,7 +1638,6 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -1671,7 +1652,6 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1692,7 +1672,6 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -1816,7 +1795,6 @@ async fn remote_compact_trims_tool_search_output_to_empty_tools_array() -> Resul codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Find the oversized deferred tool".to_string(), text_elements: Vec::new(), @@ -1897,7 +1875,6 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "turn that exceeds token threshold".into(), text_elements: Vec::new(), @@ -1912,7 +1889,6 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -2007,7 +1983,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2025,7 +2000,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2117,7 +2091,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2135,7 +2108,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2212,7 +2184,6 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -2294,7 +2265,6 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -2379,7 +2349,6 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "needs compaction".into(), text_elements: Vec::new(), @@ -2523,7 +2492,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -2542,7 +2510,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -2568,7 +2535,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -2665,7 +2631,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -2683,7 +2648,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -2756,7 +2720,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2771,7 +2734,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2839,7 +2801,6 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2901,7 +2862,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2918,7 +2878,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2994,7 +2953,6 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3012,7 +2970,6 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3096,7 +3053,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "SETUP_USER".to_string(), text_elements: Vec::new(), @@ -3113,7 +3069,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3205,7 +3160,6 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3236,7 +3190,6 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3318,7 +3271,9 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), + environments: Some(local_selections( + test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs(), + )), ..Default::default() }, ) @@ -3326,7 +3281,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us } codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -3414,7 +3368,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -3437,7 +3390,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await?; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -3557,7 +3509,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3572,7 +3523,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3658,7 +3608,6 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3737,7 +3686,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3824,7 +3772,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3842,7 +3789,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3925,7 +3871,6 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 395f3158dd..1b7c50936c 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -605,7 +605,6 @@ async fn capture_from_requests( async fn submit_user_input(codex: &codex_core::CodexThread, items: Vec) -> Result<()> { codex .submit(Op::UserInput { - environments: None, items, final_output_json_schema: None, responsesapi_client_metadata: None, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 47d1d1bb58..06ef8f62a1 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -34,6 +34,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -551,7 +552,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { core_test_support::submit_thread_settings( &conversation, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(override_cwd.clone()), + environments: Some(local_selections(override_cwd.clone())), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -774,7 +775,6 @@ async fn start_test_conversation( async fn user_turn(conversation: &Arc, text: &str) { conversation .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 25292149a0..e0d198771d 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -17,6 +17,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -51,12 +52,11 @@ async fn submit_user_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -210,12 +210,11 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { text: "run shell command".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index a1eb7eba32..b4898ea827 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -50,7 +50,6 @@ async fn fork_thread_twice_drops_to_first_message() { for text in ["first", "second", "third"] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -173,7 +172,6 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "fork me from stored history".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index 32808dc434..ae87350c39 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -19,6 +19,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -111,7 +112,6 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "run a command that requires Guardian review".into(), text_elements: Vec::new(), @@ -120,7 +120,7 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 5813508e7a..16412c2d27 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1843,7 +1843,6 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "initial prompt".to_string(), text_elements: Vec::new(), @@ -1863,7 +1862,6 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu for text in ["accepted queued prompt", "blocked queued prompt"] { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index bcfa0b7392..199a7d1db6 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -18,6 +18,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -125,12 +126,11 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu text_elements: Vec::new(), }, ], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -224,12 +224,11 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> text_elements: Vec::new(), }, ], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index a0010b6ffe..a39a2b5e05 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -35,6 +35,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -56,12 +57,11 @@ fn disabled_plan_turn( text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -117,7 +117,6 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -174,7 +173,6 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "please summarize results".into(), text_elements: Vec::new(), @@ -236,7 +234,6 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "explain your reasoning".into(), text_elements: Vec::new(), @@ -299,7 +296,6 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "find the weather".into(), text_elements: Vec::new(), @@ -380,7 +376,6 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "generate a tiny blue square".into(), text_elements: Vec::new(), @@ -468,7 +463,6 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), @@ -525,7 +519,6 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "please stream text".into(), text_elements: Vec::new(), @@ -1110,7 +1103,6 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "reason through it".into(), text_elements: Vec::new(), @@ -1166,7 +1158,6 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "show raw reasoning".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index e65d290b87..c9a855e6af 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -8,6 +8,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -81,12 +82,11 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { text: "hello world".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index b0c1910d60..49d3c0f2a6 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -32,6 +32,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; @@ -73,12 +74,11 @@ async fn submit_user_turn( text: text.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 15ed519af7..38c95394ea 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -127,7 +127,6 @@ async fn response_body_for_remote_model( text: "list tools".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -277,7 +276,6 @@ async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> text: ROOT_PROMPT.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b0d4bb558d..d176b5bc24 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -32,6 +32,7 @@ use core_test_support::responses::sse_completed; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -45,12 +46,11 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - turn_permission_fields(PermissionProfile::read_only(), test.cwd_path()); Op::UserInput { items, - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 25dd52174d..1623462182 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -1,5 +1,6 @@ #![allow(clippy::expect_used)] +use core_test_support::test_codex::local_selections; use std::fs; use std::sync::Arc; @@ -124,12 +125,11 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { text: "first turn".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(first_turn_cwd), + environments: Some(local_selections(first_turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(first_sandbox_policy), permission_profile: first_permission_profile, @@ -160,12 +160,11 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { text: "second turn with context updates".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(preturn_context_diff_cwd), + environments: Some(local_selections(preturn_context_diff_cwd)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(second_sandbox_policy), permission_profile: second_permission_profile, @@ -252,12 +251,11 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R text: "first turn in agents_one".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_one.clone()), + environments: Some(local_selections(cwd_one.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(first_sandbox_policy), permission_profile: first_permission_profile, @@ -286,12 +284,11 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R text: "second turn in agents_two".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_two), + environments: Some(local_selections(cwd_two)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(second_sandbox_policy), permission_profile: second_permission_profile, @@ -366,7 +363,6 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul .await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -413,12 +409,11 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text: "resume and change personality".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(resume_override_cwd), + environments: Some(local_selections(resume_override_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -483,7 +478,6 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - .await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -517,7 +511,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - core_test_support::submit_thread_settings( &resumed.codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(resume_override_cwd), + environments: Some(local_selections(resume_override_cwd)), model: Some("gpt-5.2".to_string()), ..Default::default() }, @@ -526,7 +520,6 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first resumed turn after model override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 485a51346d..b343c4bbaf 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -1,3 +1,4 @@ +use core_test_support::test_codex::local_selections; use std::path::Path; use std::sync::Arc; @@ -97,12 +98,11 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { text: "hi".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 11935ca27f..8b2e9f35b1 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] +use core_test_support::test_codex::local_selections; use std::sync::Arc; use std::time::Duration; @@ -110,12 +111,11 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch text: "please run a tool".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index d6c7577a7f..95811d3f63 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -120,7 +120,6 @@ async fn responses_api_emits_api_request_event() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -167,7 +166,6 @@ async fn process_sse_emits_tracing_for_output_item() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -214,7 +212,6 @@ async fn process_sse_emits_failed_event_on_parse_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -262,7 +259,6 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -330,7 +326,6 @@ async fn process_sse_failed_event_records_response_error_message() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -396,7 +391,6 @@ async fn process_sse_failed_event_logs_parse_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -449,7 +443,6 @@ async fn process_sse_failed_event_logs_missing_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -511,7 +504,6 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -567,7 +559,6 @@ async fn process_sse_emits_completed_telemetry() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -647,7 +638,6 @@ async fn turn_and_completed_response_spans_record_token_usage() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -736,7 +726,6 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -831,7 +820,6 @@ async fn record_responses_sets_span_fields_for_response_events() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -921,7 +909,6 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -998,7 +985,6 @@ async fn handle_response_item_records_tool_result_for_function_call() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1076,7 +1062,6 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1250,7 +1235,6 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1306,7 +1290,6 @@ async fn handle_shell_command_user_approved_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "approved".into(), text_elements: Vec::new(), @@ -1377,7 +1360,6 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1448,7 +1430,6 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "retry".into(), text_elements: Vec::new(), @@ -1519,7 +1500,6 @@ async fn handle_shell_command_user_denies_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), @@ -1590,7 +1570,6 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1662,7 +1641,6 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index e42703dff8..beb553e4ea 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::Op; use core_test_support::TempDirExt; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use tempfile::TempDir; @@ -68,7 +69,7 @@ async fn thread_settings_update_without_user_turn_does_not_record_environment_up core_test_support::submit_thread_settings( &test.codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.abs()), + environments: Some(local_selections(new_cwd.abs())), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 8a6443b6ce..55a00a2110 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -1,3 +1,4 @@ +use core_test_support::test_codex::local_selections; use std::sync::Arc; use codex_core::CodexThread; @@ -96,7 +97,6 @@ async fn build_codex(server: &StreamingSseServer) -> Arc { async fn submit_user_input(codex: &CodexThread, text: &str) { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -119,12 +119,11 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { text: text.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -288,7 +287,6 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first prompt".into(), text_elements: Vec::new(), @@ -308,7 +306,6 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "second prompt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 38c0fe0911..e5a69962bc 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -51,7 +51,6 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -92,7 +91,6 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -116,7 +114,6 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -163,7 +160,6 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -178,7 +174,6 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -225,7 +220,6 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -249,7 +243,6 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -309,7 +302,6 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -334,7 +326,6 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -351,7 +342,6 @@ async fn resume_replays_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -412,7 +402,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -437,7 +426,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -460,7 +448,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -496,7 +483,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { forked .thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -558,7 +544,6 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 4bd335bb35..06c2fc41f9 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -26,6 +26,7 @@ use core_test_support::responses::sse_completed; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -65,12 +66,11 @@ fn read_only_text_turn_with_personality( text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 03b1afdfaa..412b5edbbc 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -180,7 +180,6 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() codex .submit(Op::UserInput { - environments: None, items: vec![codex_protocol::user_input::UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -261,7 +260,6 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), @@ -345,7 +343,6 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 640c1c1db1..80dee582ad 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -25,6 +25,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -153,7 +154,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -168,7 +168,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -251,7 +250,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -266,7 +264,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -334,7 +331,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -349,7 +345,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -435,7 +430,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // First turn codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -474,7 +468,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // Second turn after overrides codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -572,7 +565,6 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -729,7 +721,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res // First turn codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -759,12 +750,11 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text: "hello 2".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.abs()), + environments: Some(local_selections(new_cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -876,12 +866,11 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 1".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -905,12 +894,11 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 2".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1019,12 +1007,11 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 1".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1050,12 +1037,11 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 2".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 904c116cfb..33f7d77fa7 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -41,7 +41,6 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "quota?".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index a2aec5c95f..1c6763aeba 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2158,7 +2158,6 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result let prefixed_user_text = format!("[USER] {user_text}"); test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user_text.to_string(), text_elements: Vec::new(), @@ -2294,7 +2293,6 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re ); test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user_text.clone(), text_elements: Vec::new(), @@ -3491,7 +3489,6 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 9363bdd3bd..00f5a0c514 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -41,6 +41,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_env; use core_test_support::wait_for_event; @@ -70,18 +71,21 @@ async fn submit_turn_with_approval_and_environments( prompt: &str, environments: Vec, ) -> Result<()> { + let turn_environment_selections = codex_protocol::protocol::TurnEnvironmentSelections::new( + test.config.cwd.clone(), + environments, + ); test.codex .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], - environments: Some(environments), final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(turn_environment_selections), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), @@ -284,10 +288,7 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { let test = unified_exec_test(&server).await?; let local_cwd = TempDir::new()?; fs::write(local_cwd.path().join("marker.txt"), "local-routing")?; - let local_selection = TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }; + let local_selection = local(local_cwd.path().abs()); let remote_cwd = PathBuf::from(format!( "/tmp/codex-remote-routing-{}", SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() @@ -462,10 +463,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result &test, "request permissions, then write in the remote environment", vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), @@ -601,10 +599,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< test.submit_turn_with_environments( "apply patch to remote environment", Some(vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), @@ -682,10 +677,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { .await?; let environments = vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), @@ -882,10 +874,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm test.submit_turn_with_environments( "apply patch through exec command to remote environment", Some(vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 76e7a1c29e..dc8df93c9b 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -37,6 +37,7 @@ use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -158,7 +159,6 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind text: "check context window".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -226,7 +226,6 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re text: "check context window".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -293,7 +292,6 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re text: "check context window".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -374,7 +372,6 @@ async fn remote_models_long_model_slug_is_sent_with_custom_reasoning() -> Result text: "check model slug".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -425,7 +422,6 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() text: "check namespaced model metadata".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -583,12 +579,11 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { text: "run call".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -811,12 +806,11 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { text: "hello remote".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fe9cb1e5f8..e70346a5a3 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -40,7 +40,6 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "compress me".into(), text_elements: Vec::new(), @@ -91,7 +90,6 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "do not compress".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 3beecfc33e..f78ccf1ac7 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -31,6 +31,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -195,12 +196,11 @@ async fn submit_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 9b8761c85c..dc08160d7f 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -28,6 +28,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -147,12 +148,11 @@ async fn submit_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 50cabe416e..902c7a8bb0 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use core_test_support::test_codex::local_selections; use std::collections::HashMap; use codex_features::Feature; @@ -142,12 +143,11 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul text: "please confirm".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -286,12 +286,11 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re text: "please confirm".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -391,12 +390,11 @@ where text: "please confirm".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 8492f08fdd..768bbf11db 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -20,6 +20,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use pretty_assertions::assert_eq; @@ -150,12 +151,11 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 0dc7eaddcb..8f760db417 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -86,7 +86,6 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record some messages".into(), text_elements: text_elements.clone(), @@ -173,7 +172,6 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record reasoning messages".into(), text_elements: Vec::new(), @@ -264,7 +262,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -308,7 +305,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Resume with different model".into(), text_elements: Vec::new(), @@ -327,7 +323,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Second turn after resume".into(), text_elements: Vec::new(), @@ -401,7 +396,6 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu .await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -440,7 +434,6 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn after override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 5f07d0b39c..3fdd5f83ef 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -23,6 +23,7 @@ use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -705,7 +706,6 @@ async fn review_history_surfaces_in_parent_session() { let followup = "back to parent".to_string(); codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: followup.clone(), text_elements: Vec::new(), @@ -821,7 +821,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(repo_path.to_path_buf().abs()), + environments: Some(local_selections(repo_path.to_path_buf().abs())), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 9745254272..5cb61fa4a1 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; use anyhow::ensure; +use core_test_support::test_codex::local_selections; use std::collections::HashMap; use std::ffi::OsStr; use std::ffi::OsString; @@ -128,12 +129,11 @@ fn user_turn_with_permission_profile( text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index d21299b1ea..a271702c9c 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -21,6 +21,7 @@ use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -42,12 +43,11 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { text: text.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index dd8fe60d36..686b9b9e42 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -524,7 +524,6 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let test = builder.build(&server).await?; test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Find the calendar create tool".to_string(), text_elements: Vec::new(), @@ -934,7 +933,6 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Use the automation tool".to_string(), text_elements: Vec::new(), @@ -1245,7 +1243,6 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Find the rmcp echo tool and call it.".to_string(), text_elements: Vec::new(), @@ -1563,7 +1560,6 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Search for the dynamic tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 61ca286005..b6f38b29b1 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -14,6 +14,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -164,12 +165,11 @@ async fn run_snapshot_command_with_options( text: "run unified exec with shell snapshot".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -265,12 +265,11 @@ async fn run_shell_command_snapshot_with_options( text: "run shell_command with shell snapshot".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -346,12 +345,11 @@ async fn run_tool_turn_on_harness( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -590,12 +588,11 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { text: "apply patch via shell_command with snapshot".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.clone()), + environments: Some(local_selections(cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d60d719cc8..50ea9495ac 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -13,6 +13,7 @@ use core_test_support::responses::mount_function_call_agent_response; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; @@ -51,12 +52,11 @@ async fn submit_turn_with_policies( text: prompt.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index c559615d4e..45b52c6ace 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -16,6 +16,7 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use std::sync::Arc; @@ -85,12 +86,11 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { path: skill_path.clone(), }, ], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index eb9f11a86d..b4809e8a39 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -30,6 +30,7 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -148,7 +149,6 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res started .thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "persist this thread".to_string(), text_elements: Vec::new(), @@ -560,12 +560,11 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< text: "call the rmcp echo tool".to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index d82692c26c..1870feab9f 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -94,7 +94,6 @@ async fn continue_after_stream_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -117,7 +116,6 @@ async fn continue_after_stream_error() { // error above, this submission would be rejected/queued indefinitely. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 471c60db5a..6e028279b1 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -76,7 +76,6 @@ async fn retries_on_early_close() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 391b6e5236..4d6cd17bb8 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -30,6 +30,7 @@ use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event_match; @@ -768,12 +769,11 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() text: INTERNAL_SUBAGENT_PROMPT.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index d628c8c49d..7b3f2a6902 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] +use core_test_support::test_codex::local_selections; use std::fs; use assert_matches::assert_matches; @@ -108,12 +109,11 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res text: "please run the shell command".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -190,12 +190,11 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { text: "please update the plan".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -282,12 +281,11 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { text: "please update the plan".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -384,12 +382,11 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() text: "please apply a patch".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -523,12 +520,11 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { text: "please apply a patch".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index f7f046fef7..8a9db06803 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used)] +use core_test_support::test_codex::local_selections; use std::fs; use std::time::Duration; use std::time::Instant; @@ -42,12 +43,11 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -369,12 +369,11 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an text: "stream delayed completion".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 7949abfb60..fd19a18c76 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -16,7 +16,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::TurnEnvironmentSelection; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -29,6 +28,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use regex_lite::Regex; use serde_json::Value; @@ -117,10 +117,7 @@ async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<( test.submit_turn_with_environments( "which tools are available?", - Some(vec![TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: test.config.cwd.clone(), - }]), + Some(vec![local(test.config.cwd.clone())]), ) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 35a6b0df6d..89ce0013eb 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -22,6 +22,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_mcp_server; @@ -524,12 +525,11 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { text: "call the rmcp image tool".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(fixture.cwd.abs()), + environments: Some(local_selections(fixture.cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 3eb2c6a6d3..caea5ecf55 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,3 +1,4 @@ +use core_test_support::test_codex::local_selections; use std::collections::HashMap; use std::ffi::OsStr; use std::fs; @@ -198,12 +199,11 @@ async fn submit_unified_exec_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -290,12 +290,11 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { text: "apply patch via unified exec".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2146,12 +2145,11 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2250,12 +2248,11 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { text: "interrupt long-running unified exec".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2723,12 +2720,11 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { text: "summarize large output".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2846,12 +2842,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { text: "read the fixture files".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2985,12 +2980,11 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { text: "start python under seatbelt".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs index 96ad8ee59a..0d913d6541 100644 --- a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs +++ b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs @@ -31,6 +31,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; @@ -406,12 +407,11 @@ async fn submit_turn_with_session_permissions( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 054d926b2b..328a1c972a 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -57,7 +57,6 @@ mv "${tmp_path}" "${payload_path}""#, // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index fe9715f61e..f7aed6b473 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -21,6 +21,7 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -179,12 +180,11 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> text: "run model shell command".to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 5d7263d54b..5820788566 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -42,6 +42,8 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event_with_timeout; @@ -74,12 +76,11 @@ fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); Op::UserInput { items, - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -456,10 +457,7 @@ async fn view_image_routes_to_selected_local_environment() -> anyhow::Result<()> test.submit_turn_with_environments( "route local view image", - Some(vec![TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: test.config.cwd.clone(), - }]), + Some(vec![local(test.config.cwd.clone())]), ) .await?; @@ -572,10 +570,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let test = builder.build_with_remote_and_local_env(&server).await?; let local_cwd = TempDir::new()?; fs::write(local_cwd.path().join("remote.png"), b"not a remote image")?; - let local_selection = TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }; + let local_selection = local(local_cwd.path().abs()); let remote_cwd = PathBuf::from(format!( "/tmp/codex-view-image-routing-{}", SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index b41f8be963..cdf77694c2 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -14,6 +14,7 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use pretty_assertions::assert_eq; @@ -158,12 +159,11 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 08d8867f31..7c28eb80a7 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -104,7 +104,6 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index c6b87e1d4f..cb5ddda0a8 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -23,6 +23,7 @@ codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-image = { workspace = true } http = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/ext/image-generation/imagegen_description.md b/codex-rs/ext/image-generation/imagegen_description.md index 1e5a46390f..53e6620135 100644 --- a/codex-rs/ext/image-generation/imagegen_description.md +++ b/codex-rs/ext/image-generation/imagegen_description.md @@ -5,8 +5,13 @@ The `image_gen.imagegen` tool enables image generation from descriptions and edi Guidelines: - In code mode, pass the result to `generatedImage(result)`. -- Set `action` to `generate` when the user asks for a brand new image. -- Set `action` to `edit` when the user asks to modify an existing image from the conversation history. -- Directly generate the image without reconfirmation or clarification. +- Omit both `referenced_image_paths` and `num_last_images_to_include` when generating a brand new image. +- For edits, use `referenced_image_paths` when every target image has a local file path. +- If you have not seen a local image yet, use `view_image` to inspect it before editing. +- Use `num_last_images_to_include` only when at least one target image has no local file path. +- Set `num_last_images_to_include` to the smallest number of recent conversation images that includes every target image, up to 5. +- Never provide both `referenced_image_paths` and `num_last_images_to_include`. +- If neither mechanism can include every target image, ask the user to attach the missing images again. +- Directly generate the image without reconfirmation or clarification unless required images must be attached again. - After each image generation, do not mention anything related to download. Do not summarize the image. Do not ask followup question. Do not say ANYTHING after you generate an image. - Always use this tool for image editing unless the user explicitly requests otherwise. Do not use the `python` tool for image editing unless specifically instructed. diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 6a974e2d51..3f72972656 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -19,10 +19,9 @@ use pretty_assertions::assert_eq; use super::GeneratedImageOutput; use super::ImageRequest; -use super::ImagegenAction; use super::ImagegenArgs; use super::imagegen_tool_spec; -use super::request_for_action; +use super::request_for_args; use crate::IMAGE_GEN_NAMESPACE; use crate::IMAGEGEN_TOOL_NAME; @@ -39,10 +38,17 @@ fn uses_reserved_image_gen_namespace() { } #[test] -fn generate_uses_fixed_request_defaults() { +fn omitted_references_generate_with_fixed_defaults() { assert_eq!( - request_for_action(&args(ImagegenAction::Generate, "paint a moonlit lake"), &[]) - .expect("generation request should build"), + request_for_args( + &ImagegenArgs { + prompt: "paint a moonlit lake".to_string(), + referenced_image_paths: None, + num_last_images_to_include: None, + }, + &[] + ) + .expect("generation request should build"), ImageRequest::Generate(ImageGenerationRequest { prompt: "paint a moonlit lake".to_string(), background: Some(ImageBackground::Auto), @@ -54,6 +60,144 @@ fn generate_uses_fixed_request_defaults() { ); } +#[test] +fn recent_image_fallback_selects_newest_images_in_chronological_order() { + let history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + input_image("user-1"), + input_image("user-2"), + ContentItem::InputText { + text: "edit these".to_string(), + }, + ], + phase: None, + }, + ResponseItem::FunctionCall { + id: None, + name: "mcp_image".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "mcp-call".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "mcp-call".to_string(), + output: image_output("mcp"), + }, + ResponseItem::CustomToolCall { + id: None, + status: Some("completed".to_string()), + call_id: "code-mode-call".to_string(), + name: "exec".to_string(), + input: String::new(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "code-mode-call".to_string(), + name: Some("exec".to_string()), + output: image_output("code-mode"), + }, + ResponseItem::ImageGenerationCall { + id: "generated-call".to_string(), + status: "completed".to_string(), + revised_prompt: None, + result: "generated".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "orphan-call".to_string(), + output: image_output("orphan"), + }, + ]; + + assert_eq!( + request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: None, + num_last_images_to_include: Some(4), + }, + &history, + ) + .expect("history-backed edit request should build"), + ImageRequest::Edit(expected_edit_request( + "change the lighting", + &["user-2", "mcp", "code-mode", "generated"], + )) + ); +} + +#[test] +fn conflicting_image_selectors_return_tool_error() { + let error = request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: Some(vec![ + "/tmp/image.png" + .try_into() + .expect("test path should be absolute"), + ]), + num_last_images_to_include: Some(1), + }, + &[], + ) + .expect_err("conflicting selectors should fail"); + + assert_eq!( + error.to_string(), + "provide only one of `referenced_image_paths` or `num_last_images_to_include`" + ); +} + +#[test] +fn too_many_referenced_image_paths_return_tool_error() { + let error = request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: Some( + (0..6) + .map(|index| { + format!("/tmp/image-{index}.png") + .try_into() + .expect("test path should be absolute") + }) + .collect(), + ), + num_last_images_to_include: None, + }, + &[], + ) + .expect_err("too many paths should fail before reading files"); + + assert_eq!( + error.to_string(), + "`referenced_image_paths` must contain at most 5 paths" + ); +} + +#[test] +fn recent_image_fallback_requires_requested_count() { + let error = request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: None, + num_last_images_to_include: Some(2), + }, + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![input_image("only-image")], + phase: None, + }], + ) + .expect_err("history-backed edit should require the requested image count"); + + assert_eq!( + error.to_string(), + "requested the last 2 conversation images, but only 1 were available" + ); +} + #[test] fn generated_output_returns_image_input_and_output_hint() { let output_hint = @@ -128,195 +272,26 @@ fn generated_output_omits_oversized_output_hint() { ); } -#[test] -fn edit_matches_context_selector_for_generated_images_after_latest_user_anchor() { - let history = vec![ - generated_item("g1"), - generated_item("g2"), - generated_item("g3"), - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputImage { - image_url: "data:image/png;base64,u1".to_string(), - detail: None, - }, - ContentItem::InputImage { - image_url: "data:image/png;base64,u2".to_string(), - detail: None, - }, - ], - phase: None, - }, - generated_item("g4"), - generated_item("g5"), - generated_item("g6"), - generated_item("g7"), - ]; - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request( - "change the lighting", - &[ - "data:image/png;base64,u1", - "data:image/png;base64,u2", - "data:image/png;base64,g5", - "data:image/png;base64,g6", - "data:image/png;base64,g7", - ] - ) - ); -} - -#[test] -fn edit_preserves_a_generated_image_when_user_anchor_fills_the_limit() { - let history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: ["a", "b", "c", "d", "e"] - .into_iter() - .map(|image| ContentItem::InputImage { - image_url: format!("data:image/png;base64,{image}"), - detail: None, - }) - .collect(), - phase: None, - }, - generated_item("generated"), - ]; - - assert_eq!( - edit_request("edit the last generated image", &history), - expected_edit_request( - "edit the last generated image", - &[ - "data:image/png;base64,b", - "data:image/png;base64,c", - "data:image/png;base64,d", - "data:image/png;base64,e", - "data:image/png;base64,generated", - ] - ) - ); -} - -#[test] -fn edit_uses_latest_user_upload_before_a_text_only_follow_up() { - let history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputImage { - image_url: "data:image/png;base64,user".to_string(), - detail: None, - }], - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "edit this image".to_string(), - }], - phase: None, - }, - ]; - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request("change the lighting", &["data:image/png;base64,user"]) - ); -} - -#[test] -fn edit_reuses_images_from_prior_standalone_imagegen_calls() { - let history = vec![ - ResponseItem::FunctionCall { - id: None, - name: IMAGEGEN_TOOL_NAME.to_string(), - namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), - arguments: "{}".to_string(), - call_id: "imagegen-1".to_string(), - }, - generated_function_output("imagegen-1", "standalone"), - ]; - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request("change the lighting", &["data:image/png;base64,standalone"]) - ); -} - -#[test] -fn edit_keeps_newest_standalone_generated_images_when_over_limit() { - let history = (1..=6) - .flat_map(|index| { - let call_id = format!("imagegen-{index}"); - vec![ - ResponseItem::FunctionCall { - id: None, - name: IMAGEGEN_TOOL_NAME.to_string(), - namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), - arguments: "{}".to_string(), - call_id: call_id.clone(), - }, - generated_function_output(&call_id, &index.to_string()), - ] - }) - .collect::>(); - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request( - "change the lighting", - &[ - "data:image/png;base64,2", - "data:image/png;base64,3", - "data:image/png;base64,4", - "data:image/png;base64,5", - "data:image/png;base64,6", - ] - ) - ); -} - -#[test] -fn edit_without_image_history_returns_tool_error() { - let error = request_for_action(&args(ImagegenAction::Edit, "change the lighting"), &[]) - .expect_err("edit should require image context"); - - assert_eq!( - error.to_string(), - "image edit requested without any usable image in conversation history" - ); -} - -fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs { - ImagegenArgs { - prompt: prompt.to_string(), - action, +fn input_image(image: &str) -> ContentItem { + ContentItem::InputImage { + image_url: format!("data:image/png;base64,{image}"), + detail: None, } } -fn edit_request(prompt: &str, history: &[ResponseItem]) -> ImageEditRequest { - let ImageRequest::Edit(request) = - request_for_action(&args(ImagegenAction::Edit, prompt), history) - .expect("edit request should build") - else { - panic!("expected edit request"); - }; - request +fn image_output(image: &str) -> FunctionCallOutputPayload { + FunctionCallOutputPayload::from_content_items(vec![FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{image}"), + detail: None, + }]) } fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest { ImageEditRequest { images: images .iter() - .map(|image_url| ImageUrl { - image_url: (*image_url).to_string(), + .map(|image| ImageUrl { + image_url: format!("data:image/png;base64,{image}"), }) .collect(), prompt: prompt.to_string(), @@ -328,33 +303,6 @@ fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest { } } -fn generated_item(result: &str) -> ResponseItem { - ResponseItem::ImageGenerationCall { - id: format!("id-{result}"), - status: "completed".to_string(), - revised_prompt: None, - result: result.to_string(), - } -} - -fn generated_function_output(call_id: &str, result: &str) -> ResponseItem { - ResponseItem::FunctionCallOutput { - call_id: call_id.to_string(), - output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::ContentItems(vec![ - FunctionCallOutputContentItem::InputImage { - image_url: format!("data:image/png;base64,{result}"), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: "generated image save hint".to_string(), - }, - ]), - success: Some(true), - }, - } -} - fn function_payload() -> ToolPayload { ToolPayload::Function { arguments: "{}".to_string(), diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 41c06c8007..69b99bdbc4 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use codex_api::ImageBackground; use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; @@ -28,6 +30,8 @@ use codex_tools::ResponsesApiTool; use codex_tools::ToolExposure; use codex_tools::default_namespace_description; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_image::PromptImageMode; +use codex_utils_image::load_for_prompt_bytes; use schemars::JsonSchema; use schemars::r#gen::SchemaSettings; use serde::Deserialize; @@ -68,14 +72,10 @@ impl ImageGenerationTool { #[serde(deny_unknown_fields)] struct ImagegenArgs { prompt: String, - action: ImagegenAction, -} - -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -enum ImagegenAction { - Generate, - Edit, + #[schemars(length(max = 5))] + referenced_image_paths: Option>, + #[schemars(range(min = 1, max = 5))] + num_last_images_to_include: Option, } #[async_trait::async_trait] @@ -85,7 +85,7 @@ impl ToolExecutor for ImageGenerationTool { ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) } - /// Advertises the model contract: a rewritten prompt and semantic action. + /// Advertises the model contract: a rewritten prompt and optional edit references. fn spec(&self) -> ToolSpec { imagegen_tool_spec() } @@ -98,7 +98,7 @@ impl ToolExecutor for ImageGenerationTool { /// Executes the selected image operation and returns the completed image result. async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let args = parse_args(&call)?; - let request = request_for_action(&args, call.conversation_history.items())?; + let request = request_for_args(&args, call.conversation_history.items())?; call.turn_item_emitter .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { id: call.call_id.clone(), @@ -149,106 +149,84 @@ enum ImageRequest { Edit(ImageEditRequest), } -/// Maps the model-selected action to the fixed image API request parameters. -fn request_for_action( +/// Builds a generation or edit request from the mutually exclusive image selectors. +fn request_for_args( args: &ImagegenArgs, history: &[ResponseItem], ) -> Result { - match args.action { - ImagegenAction::Generate => Ok(ImageRequest::Generate(ImageGenerationRequest { - prompt: args.prompt.clone(), - background: Some(ImageBackground::Auto), - model: IMAGE_MODEL.to_string(), - n: None, - quality: Some(ImageQuality::Auto), - size: Some("auto".to_string()), - })), - ImagegenAction::Edit => { - let images = edit_images(history); - if images.is_empty() { - return Err(FunctionCallError::RespondToModel( - "image edit requested without any usable image in conversation history" - .to_string(), - )); - } - Ok(ImageRequest::Edit(ImageEditRequest { - images, + let paths = args.referenced_image_paths.as_deref().unwrap_or_default(); + if paths.len() > MAX_EDIT_IMAGES { + return Err(FunctionCallError::RespondToModel(format!( + "`referenced_image_paths` must contain at most {MAX_EDIT_IMAGES} paths" + ))); + } + let images = match (paths.is_empty(), args.num_last_images_to_include) { + (true, None) => { + return Ok(ImageRequest::Generate(ImageGenerationRequest { prompt: args.prompt.clone(), background: Some(ImageBackground::Auto), model: IMAGE_MODEL.to_string(), n: None, quality: Some(ImageQuality::Auto), size: Some("auto".to_string()), - })) + })); } - } + (false, None) => paths.iter().map(image_url).collect::, _>>()?, + (true, Some(count)) => { + if !(1..=MAX_EDIT_IMAGES).contains(&count) { + return Err(FunctionCallError::RespondToModel(format!( + "`num_last_images_to_include` must be between 1 and {MAX_EDIT_IMAGES}" + ))); + } + // Pathless images have no stable reference, so this bounded window may include newer + // unrelated images. This remains best-effort until the harness provides stable refs. + let images = recent_images(history, count); + if images.len() != count { + return Err(FunctionCallError::RespondToModel(format!( + "requested the last {count} conversation images, but only {} were available", + images.len() + ))); + } + images + } + (false, Some(_)) => { + return Err(FunctionCallError::RespondToModel( + "provide only one of `referenced_image_paths` or \ + `num_last_images_to_include`" + .to_string(), + )); + } + }; + + Ok(ImageRequest::Edit(ImageEditRequest { + images, + prompt: args.prompt.clone(), + background: Some(ImageBackground::Auto), + model: IMAGE_MODEL.to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + })) } -/// Selects edit context using the hosted imagegen anchor and truncation behavior. -fn edit_images(history: &[ResponseItem]) -> Vec { - let latest_uploaded_images = history.iter().enumerate().rev().find_map(|(index, item)| { - let ResponseItem::Message { role, content, .. } = item else { - return None; - }; - if role != "user" { - return None; - } - let images = content - .iter() - .filter_map(|item| match item { - ContentItem::InputImage { image_url, .. } => Some(ImageUrl { - image_url: image_url.clone(), - }), - ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None, - }) - .collect::>(); - (!images.is_empty()).then_some((index, images)) - }); - let (user_images, follow_up_start) = latest_uploaded_images - .map_or_else(|| (Vec::new(), 0), |(index, images)| (images, index + 1)); - let mut generated_images = Vec::new(); - for item in &history[follow_up_start..] { +/// Selects the newest requested images while preserving their conversation order. +fn recent_images(history: &[ResponseItem], count: usize) -> Vec { + let mut function_call_ids = HashSet::new(); + let mut custom_tool_call_ids = HashSet::new(); + for item in history { match item { - ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { - generated_images.push(ImageUrl { - image_url: format!("data:image/png;base64,{result}"), - }); + ResponseItem::FunctionCall { call_id, .. } => { + function_call_ids.insert(call_id.as_str()); } - ResponseItem::FunctionCallOutput { call_id, output } - if history.iter().any(|item| { - matches!( - item, - ResponseItem::FunctionCall { - name, - namespace: Some(namespace), - call_id: function_call_id, - .. - } if function_call_id == call_id - && name == IMAGEGEN_TOOL_NAME - && namespace == IMAGE_GEN_NAMESPACE - ) - }) => - { - generated_images.extend(output.content_items().into_iter().flatten().filter_map( - |item| match item { - FunctionCallOutputContentItem::InputImage { image_url, .. } => { - Some(ImageUrl { - image_url: image_url.clone(), - }) - } - FunctionCallOutputContentItem::InputText { .. } - | FunctionCallOutputContentItem::EncryptedContent { .. } => None, - }, - )); + ResponseItem::CustomToolCall { call_id, .. } => { + custom_tool_call_ids.insert(call_id.as_str()); } ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::WebSearchCall { .. } @@ -259,25 +237,89 @@ fn edit_images(history: &[ResponseItem]) -> Vec { | ResponseItem::Other => {} } } - truncate_images(user_images, generated_images) + + let mut images = Vec::with_capacity(count); + 'history: for item in history.iter().rev() { + let mut image_urls = Vec::new(); + match item { + ResponseItem::Message { content, .. } => { + image_urls.extend(content.iter().rev().filter_map(|item| match item { + ContentItem::InputImage { image_url, .. } => Some(image_url.clone()), + ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None, + })); + } + ResponseItem::FunctionCallOutput { call_id, output } + if function_call_ids.contains(call_id.as_str()) => + { + image_urls.extend(output_image_urls(output)); + } + ResponseItem::CustomToolCallOutput { + call_id, output, .. + } if custom_tool_call_ids.contains(call_id.as_str()) => { + image_urls.extend(output_image_urls(output)); + } + ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { + image_urls.push(format!("data:image/png;base64,{result}")); + } + ResponseItem::Reasoning { .. } + | ResponseItem::AgentMessage { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => {} + } + for image_url in image_urls { + images.push(ImageUrl { image_url }); + if images.len() == count { + break 'history; + } + } + } + images.reverse(); + images } -/// Truncates edit inputs while preserving the newest generated image when possible. -fn truncate_images( - mut user_images: Vec, - mut generated_images: Vec, -) -> Vec { - let mut excess = (user_images.len() + generated_images.len()).saturating_sub(MAX_EDIT_IMAGES); - let drop_generated = excess.min(generated_images.len().saturating_sub(1)); - generated_images.drain(..drop_generated); - excess -= drop_generated; - let drop_user = excess.min(user_images.len()); - user_images.drain(..drop_user); - excess -= drop_user; - generated_images.drain(..excess); +/// Extracts image URLs from a tool output in newest-first order. +fn output_image_urls(output: &FunctionCallOutputPayload) -> impl Iterator + '_ { + output + .content_items() + .into_iter() + .flatten() + .rev() + .filter_map(|item| match item { + FunctionCallOutputContentItem::InputImage { image_url, .. } => Some(image_url.clone()), + FunctionCallOutputContentItem::InputText { .. } + | FunctionCallOutputContentItem::EncryptedContent { .. } => None, + }) +} - user_images.extend(generated_images); - user_images +fn image_url(path: &AbsolutePathBuf) -> Result { + let bytes = std::fs::read(path).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "unable to read referenced image at `{}`: {error}", + path.display() + )) + })?; + let image = load_for_prompt_bytes(path.as_path(), bytes, PromptImageMode::Original).map_err( + |error| { + FunctionCallError::RespondToModel(format!( + "unable to process referenced image at `{}`: {error}", + path.display() + )) + }, + )?; + Ok(ImageUrl { + image_url: image.into_data_url(), + }) } /// Parses the strict model-facing arguments for an image-generation call. diff --git a/codex-rs/ext/web-search/web_run_description.md b/codex-rs/ext/web-search/web_run_description.md index bccc3d81f6..0f22f6df3c 100644 --- a/codex-rs/ext/web-search/web_run_description.md +++ b/codex-rs/ext/web-search/web_run_description.md @@ -77,4 +77,4 @@ Responses may not excessively quote or draw on a specific source. There are seve --- -Make sure to provide links to the sources you used in your response. +Cite your sources by using markdown links to the sites you used in your response. Do NOT use citations in the `turnX` style. diff --git a/codex-rs/git-utils/src/fsmonitor.rs b/codex-rs/git-utils/src/fsmonitor.rs new file mode 100644 index 0000000000..b4902ec37d --- /dev/null +++ b/codex-rs/git-utils/src/fsmonitor.rs @@ -0,0 +1,129 @@ +//! Policy for preserving Git's built-in filesystem monitor. +//! +//! Codex overrides `core.fsmonitor` so repository configuration cannot select +//! an executable helper. Preserve the built-in daemon only when the effective +//! value is boolean true and Git advertises daemon support. +//! +//! The daemon avoids scanning every tracked file and untracked directory: +//! https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/Documentation/git-fsmonitor--daemon.adoc#L49-L57 +//! https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/Documentation/git-update-index.adoc#L545-L550 + +use std::future::Future; + +/// The safe `core.fsmonitor` override for an internal Git command. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FsmonitorOverride { + /// Disable repository-selected filesystem monitor helpers. + Disabled, + /// Preserve Git's built-in filesystem monitor daemon. + BuiltIn, +} + +impl FsmonitorOverride { + /// Returns the complete Git configuration override. + pub const fn git_config_arg(self) -> &'static str { + match self { + Self::Disabled => "core.fsmonitor=false", + Self::BuiltIn => "core.fsmonitor=true", + } + } +} + +/// Executes the Git commands required by [`detect_fsmonitor_override`]. +/// +/// Implementations must return stdout only when Git exits successfully. +/// Timeouts, spawn or transport failures, signal termination, and nonzero exit +/// statuses must return `None`. +pub trait FsmonitorProbeRunner: Send { + /// Runs one bounded probe in the target repository. + fn run_probe(&mut self, args: &[&str]) -> impl Future>> + Send; +} + +/// Returns the safe filesystem monitor override for the target repository. +/// +/// This intentionally probes every time. Effective Git configuration is +/// layered, may use conditional includes, and can change while Codex is +/// running: +/// https://git-scm.com/docs/git-config#SCOPES +/// https://git-scm.com/docs/git-config#_conditional_includes +pub async fn detect_fsmonitor_override( + runner: &mut impl FsmonitorProbeRunner, +) -> FsmonitorOverride { + // A typed query converts every matching value before `--get` selects the + // effective one. A shadowed helper path can therefore make a repository- + // local true fail conversion. Query the raw effective value first. + // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L482-L514 + // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L611-L614 + let Some(config) = runner + .run_probe(&["config", "--null", "--get", "core.fsmonitor"]) + .await + else { + return FsmonitorOverride::Disabled; + }; + let Some(config) = config.strip_suffix(b"\0") else { + return FsmonitorOverride::Disabled; + }; + if config.contains(&0) { + return FsmonitorOverride::Disabled; + } + let Ok(config) = str::from_utf8(config) else { + return FsmonitorOverride::Disabled; + }; + + // Git accepts these case-insensitive spellings directly, as well as + // valueless keys and nonzero integers. Ask Git to normalize uncommon + // spellings, filtering by the raw effective value before conversion so a + // shadowed helper pathname cannot make the query fail. + // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/parse.c#L158-L181 + // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L264-L279 + // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L496-L507 + let configured = if ["true", "yes", "on"] + .iter() + .any(|value| config.eq_ignore_ascii_case(value)) + { + true + } else if ["false", "no", "off"] + .iter() + .any(|value| config.eq_ignore_ascii_case(value)) + { + false + } else { + let typed_args = [ + "config", + "--null", + "--type=bool", + "--fixed-value", + "--get", + "core.fsmonitor", + config, + ]; + matches!( + runner.run_probe(&typed_args).await.as_deref(), + Some(b"true\0") + ) + }; + if !configured { + return FsmonitorOverride::Disabled; + } + + // Git 2.35.1 and older interpret "true" as a hook pathname. Before Git + // 2.26, a successful empty hook response can hide tracked changes. Require + // the feature line Git added specifically for capability checks. + // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/Documentation/config/core.adoc#L90-L99 + // https://github.com/git/git/commit/dd77cf61a1a2fbf52c94d0cd986d555ad2ba8a4b + let Some(build_options) = runner.run_probe(&["version", "--build-options"]).await else { + return FsmonitorOverride::Disabled; + }; + if build_options + .split(|byte| *byte == b'\n') + .any(|line| line.trim_ascii() == b"feature: fsmonitor--daemon") + { + FsmonitorOverride::BuiltIn + } else { + FsmonitorOverride::Disabled + } +} + +#[cfg(test)] +#[path = "fsmonitor_tests.rs"] +mod tests; diff --git a/codex-rs/git-utils/src/fsmonitor_tests.rs b/codex-rs/git-utils/src/fsmonitor_tests.rs new file mode 100644 index 0000000000..e86139421c --- /dev/null +++ b/codex-rs/git-utils/src/fsmonitor_tests.rs @@ -0,0 +1,139 @@ +use std::collections::VecDeque; +use std::future::Future; + +use pretty_assertions::assert_eq; + +use super::FsmonitorOverride; +use super::FsmonitorProbeRunner; +use super::detect_fsmonitor_override; + +struct ProbeResponse { + args: Vec<&'static str>, + output: Option>, +} + +struct FakeRunner { + responses: VecDeque, +} + +impl FsmonitorProbeRunner for FakeRunner { + fn run_probe(&mut self, args: &[&str]) -> impl Future>> + Send { + let response = self.responses.pop_front().expect("missing probe response"); + assert_eq!(args, response.args); + std::future::ready(response.output) + } +} + +#[tokio::test] +async fn detects_supported_builtin_fsmonitor_values() { + let cases = [ + ( + "missing config", + vec![response(config_args(), /*output*/ None)], + FsmonitorOverride::Disabled, + ), + ( + "helper path", + vec![ + response(config_args(), Some(b"/tmp/fsmonitor-helper\0")), + response( + typed_config_args("/tmp/fsmonitor-helper"), + /*output*/ None, + ), + ], + FsmonitorOverride::Disabled, + ), + ( + "false spelling", + vec![response(config_args(), Some(b"OFF\0"))], + FsmonitorOverride::Disabled, + ), + ( + "unsupported Git", + vec![ + response(config_args(), Some(b"yes\0")), + response(capability_args(), Some(b"")), + ], + FsmonitorOverride::Disabled, + ), + ( + "common true spelling", + vec![ + response(config_args(), Some(b"On\0")), + response(capability_args(), Some(fsmonitor_capability())), + ], + FsmonitorOverride::BuiltIn, + ), + ( + "numeric true", + vec![ + response(config_args(), Some(b"2k\0")), + response(typed_config_args("2k"), Some(b"true\0")), + response(capability_args(), Some(fsmonitor_capability())), + ], + FsmonitorOverride::BuiltIn, + ), + ( + "valueless true", + vec![ + response(config_args(), Some(b"\0")), + response(typed_config_args(""), Some(b"true\0")), + response(capability_args(), Some(fsmonitor_capability())), + ], + FsmonitorOverride::BuiltIn, + ), + ( + "explicit empty false", + vec![ + response(config_args(), Some(b"\0")), + response(typed_config_args(""), Some(b"false\0")), + ], + FsmonitorOverride::Disabled, + ), + ]; + + for (name, responses, expected) in cases { + let mut runner = FakeRunner { + responses: responses.into(), + }; + + let actual = detect_fsmonitor_override(&mut runner).await; + + assert_eq!( + (actual, runner.responses.len()), + (expected, 0), + "case: {name}" + ); + } +} + +fn response(args: Vec<&'static str>, output: Option<&[u8]>) -> ProbeResponse { + ProbeResponse { + args, + output: output.map(<[u8]>::to_vec), + } +} + +fn config_args() -> Vec<&'static str> { + vec!["config", "--null", "--get", "core.fsmonitor"] +} + +fn typed_config_args(value: &'static str) -> Vec<&'static str> { + vec![ + "config", + "--null", + "--type=bool", + "--fixed-value", + "--get", + "core.fsmonitor", + value, + ] +} + +fn capability_args() -> Vec<&'static str> { + vec!["version", "--build-options"] +} + +fn fsmonitor_capability() -> &'static [u8] { + b"feature: fsmonitor--daemon\n" +} diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index a188d9a876..7c0d59462c 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -279,7 +279,10 @@ fn trim_git_suffix(value: &str) -> &str { } pub async fn get_has_changes(cwd: &Path) -> Option { - let output = run_git_command_with_timeout(&["status", "--porcelain"], cwd).await?; + let git = Path::new("git"); + let fsmonitor = detect_local_fsmonitor_override(git, cwd).await; + let output = + run_git_command_with_timeout_from(git, &["status", "--porcelain"], cwd, fsmonitor).await?; if !output.status.success() { return None; } @@ -390,12 +393,53 @@ pub async fn git_diff_to_remote(cwd: &Path) -> Option { /// Run a git command with a timeout to prevent blocking on large repositories async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option { - let mut command = Command::new("git"); + // These callers only inspect repository metadata. Worktree workflows probe + // once and pass their override directly to the lower-level runner. + run_git_command_with_timeout_from( + Path::new("git"), + args, + cwd, + crate::FsmonitorOverride::Disabled, + ) + .await +} + +struct LocalFsmonitorProbeRunner<'a> { + git: &'a Path, + cwd: &'a Path, +} + +impl crate::FsmonitorProbeRunner for LocalFsmonitorProbeRunner<'_> { + async fn run_probe(&mut self, args: &[&str]) -> Option> { + // Both probes are fast, bounded metadata queries that do not inspect the + // worktree or index, so do not reduce the requested command's timeout. + let mut command = Command::new(self.git); + command.args(args).current_dir(self.cwd).kill_on_drop(true); + match timeout(GIT_COMMAND_TIMEOUT, command.output()).await { + Ok(Ok(output)) if output.status.success() => Some(output.stdout), + _ => None, + } + } +} + +async fn detect_local_fsmonitor_override(git: &Path, cwd: &Path) -> crate::FsmonitorOverride { + let mut runner = LocalFsmonitorProbeRunner { git, cwd }; + crate::detect_fsmonitor_override(&mut runner).await +} + +async fn run_git_command_with_timeout_from( + git: &Path, + args: &[&str], + cwd: &Path, + fsmonitor: crate::FsmonitorOverride, +) -> Option { + let mut command = Command::new(git); command .env("GIT_OPTIONAL_LOCKS", "0") - // Keep internal Git helper commands independent of configured hook directories. + // Keep internal Git commands independent of repository-selected hooks + // and fsmonitor helpers while preserving built-in fsmonitor acceleration. .args(["-c", &format!("core.hooksPath={DISABLED_HOOKS_PATH}")]) - .args(["-c", "core.fsmonitor=false"]) + .args(["-c", fsmonitor.git_config_arg()]) .args(args) .current_dir(cwd) .kill_on_drop(true); @@ -684,9 +728,15 @@ async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) - } async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { - let output = - run_git_command_with_timeout(&["diff", "--no-textconv", "--no-ext-diff", &sha.0], cwd) - .await?; + let git = Path::new("git"); + let fsmonitor = detect_local_fsmonitor_override(git, cwd).await; + let output = run_git_command_with_timeout_from( + git, + &["diff", "--no-textconv", "--no-ext-diff", &sha.0], + cwd, + fsmonitor, + ) + .await?; // 0 is success and no diff. // 1 is success but there is a diff. let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1); @@ -695,8 +745,13 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { } let mut diff = String::from_utf8(output.stdout).ok()?; - if let Some(untracked_output) = - run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await + if let Some(untracked_output) = run_git_command_with_timeout_from( + git, + &["ls-files", "--others", "--exclude-standard"], + cwd, + fsmonitor, + ) + .await && untracked_output.status.success() { let untracked: Vec = String::from_utf8(untracked_output.stdout) @@ -722,7 +777,7 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { null_device, &file_owned, ]; - run_git_command_with_timeout(&args_vec, cwd).await + run_git_command_with_timeout_from(git, &args_vec, cwd, fsmonitor).await }); let results = join_all(futures_iter).await; for extra in results.into_iter().flatten() { @@ -849,6 +904,8 @@ pub async fn current_branch_name(cwd: &Path) -> Option { mod tests { use super::*; use pretty_assertions::assert_eq; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; #[test] fn canonicalize_git_remote_url_normalizes_github_variants() { @@ -886,4 +943,146 @@ mod tests { assert_eq!(canonicalize_git_remote_url(remote), None); } } + + #[cfg(unix)] + #[tokio::test] + async fn fsmonitor_override_rejects_configured_helper() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let git = temp_dir.path().join("git"); + let log = temp_dir.path().join("git.log"); + std::fs::write( + &git, + "#!/bin/sh\n\ + printf '%s\\n' \"$*\" >>\"$0.log\"\n\ + case \"$1\" in\n\ + config) printf '/tmp/fsmonitor-helper\\000' ;;\n\ + *) printf 'worktree output\\n' ;;\n\ + esac\n", + ) + .expect("write fake Git"); + let mut permissions = std::fs::metadata(&git) + .expect("read fake Git metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&git, permissions).expect("mark fake Git executable"); + + // The config response mirrors: + // git -c core.fsmonitor=/tmp/fsmonitor-helper \ + // config --null --get core.fsmonitor + let fsmonitor = detect_local_fsmonitor_override(&git, temp_dir.path()).await; + let output = run_git_command_with_timeout_from( + &git, + &["status", "--porcelain"], + temp_dir.path(), + fsmonitor, + ) + .await + .expect("run fake Git"); + + assert_eq!( + (output.status.code(), output.stdout), + (Some(0), b"worktree output\n".to_vec()) + ); + let disabled_hooks = format!("core.hooksPath={DISABLED_HOOKS_PATH}"); + assert_eq!( + std::fs::read_to_string(log) + .expect("read fake Git log") + .lines() + .map(str::to_string) + .collect::>(), + vec![ + "config --null --get core.fsmonitor".to_string(), + "config --null --type=bool --fixed-value --get core.fsmonitor /tmp/fsmonitor-helper" + .to_string(), + format!("-c {disabled_hooks} -c core.fsmonitor=false status --porcelain"), + ] + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn fsmonitor_override_uses_effective_layered_config_value() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let repo = temp_dir.path().join("repo"); + std::fs::create_dir(&repo).expect("create repository directory"); + let init_status = std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(&repo) + .status() + .expect("initialize test repository"); + assert_eq!(init_status.code(), Some(0), "initialize test repository"); + + let git = temp_dir.path().join("git"); + let global_config = temp_dir.path().join("git.global"); + let log = temp_dir.path().join("git.log"); + std::fs::write( + &git, + "#!/bin/sh\n\ + printf '%s\\n' \"$*\" >>\"$0.log\"\n\ + case \"$1\" in\n\ + config)\n\ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=\"$0.global\" exec git \"$@\"\n\ + ;;\n\ + version) printf 'feature: fsmonitor--daemon\\n' ;;\n\ + *) printf 'worktree output\\n' ;;\n\ + esac\n", + ) + .expect("write layered-config Git"); + let mut permissions = std::fs::metadata(&git) + .expect("read layered-config Git metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&git, permissions).expect("mark layered-config Git executable"); + + let global_status = std::process::Command::new("git") + .args([ + "config", + "--file", + global_config.to_str().expect("global config path"), + "core.fsmonitor", + "/tmp/fsmonitor-helper", + ]) + .status() + .expect("write global fsmonitor helper"); + assert_eq!( + global_status.code(), + Some(0), + "write global fsmonitor helper" + ); + let local_status = std::process::Command::new("git") + .args(["config", "core.fsmonitor", "true"]) + .current_dir(&repo) + .status() + .expect("write local built-in fsmonitor config"); + assert_eq!( + local_status.code(), + Some(0), + "write local built-in fsmonitor config" + ); + + let fsmonitor = detect_local_fsmonitor_override(&git, repo.as_path()).await; + let output = run_git_command_with_timeout_from( + &git, + &["status", "--porcelain"], + repo.as_path(), + fsmonitor, + ) + .await + .expect("run Git with layered config"); + assert_eq!( + (output.status.code(), output.stdout), + (Some(0), b"worktree output\n".to_vec()) + ); + + let actual = std::fs::read_to_string(log).expect("read layered-config Git log"); + let disabled_hooks = format!("core.hooksPath={DISABLED_HOOKS_PATH}"); + assert_eq!( + actual.lines().map(str::to_string).collect::>(), + vec![ + "config --null --get core.fsmonitor".to_string(), + "version --build-options".to_string(), + format!("-c {disabled_hooks} -c core.fsmonitor=true status --porcelain"), + ] + ); + } } diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index 986a7434d2..e04e604410 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -2,6 +2,7 @@ mod apply; mod baseline; mod branch; mod errors; +mod fsmonitor; mod info; mod operations; mod platform; @@ -21,6 +22,9 @@ pub use baseline::reset_git_repository; pub use branch::merge_base_with_head; pub use codex_protocol::protocol::GitSha; pub use errors::GitToolingError; +pub use fsmonitor::FsmonitorOverride; +pub use fsmonitor::FsmonitorProbeRunner; +pub use fsmonitor::detect_fsmonitor_override; pub use info::CommitLogEntry; pub use info::GitDiffToRemote; pub use info::GitInfo; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index fd56805d4e..c4ec218dc9 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -103,7 +103,6 @@ pub async fn run_codex_tool_session( let submission = Submission { id: sub_id.clone(), op: Op::UserInput { - environments: None, items: vec![UserInput::Text { text: initial_prompt.clone(), // MCP tool prompts are plain text with no UI element ranges. @@ -155,7 +154,6 @@ pub async fn run_codex_tool_session_reply( .insert(request_id.clone(), thread_id); if let Err(e) = thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: prompt, // MCP tool prompts are plain text with no UI element ranges. @@ -384,6 +382,7 @@ async fn run_codex_tool_session_inner( | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeBegin(_) | EventMsg::CollabResumeEnd(_) + | EventMsg::SubAgentActivity(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 58bdc9dfc4..9c15ff04e2 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -259,7 +259,6 @@ impl MemoryStartupContext { .thread .submit(Op::UserInput { items: prompt, - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), diff --git a/codex-rs/otel/src/metrics/client.rs b/codex-rs/otel/src/metrics/client.rs index 417c1f4bd9..9d5ad5d166 100644 --- a/codex-rs/otel/src/metrics/client.rs +++ b/codex-rs/otel/src/metrics/client.rs @@ -45,6 +45,12 @@ const METER_NAME: &str = "codex"; const DURATION_UNIT: &str = "ms"; const DURATION_DESCRIPTION: &str = "Duration in milliseconds."; +#[derive(Debug, Eq, Hash, PartialEq)] +struct InstrumentKey { + name: String, + description: Option, +} + #[derive(Clone, Debug)] struct SharedManualReader { inner: Arc, @@ -82,7 +88,7 @@ impl MetricReader for SharedManualReader { struct MetricsClientInner { meter_provider: SdkMeterProvider, meter: Meter, - counters: Mutex>>, + counters: Mutex>>, histograms: Mutex>>, duration_histograms: Mutex>>, runtime_reader: Option>, @@ -90,7 +96,13 @@ struct MetricsClientInner { } impl MetricsClientInner { - fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) -> Result<()> { + fn counter( + &self, + name: &str, + description: Option<&str>, + inc: i64, + tags: &[(&str, &str)], + ) -> Result<()> { validate_metric_name(name)?; if inc < 0 { return Err(MetricsError::NegativeCounterIncrement { @@ -104,9 +116,17 @@ impl MetricsClientInner { .counters .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let counter = counters - .entry(name.to_string()) - .or_insert_with(|| self.meter.u64_counter(name.to_string()).build()); + let key = InstrumentKey { + name: name.to_string(), + description: description.map(str::to_string), + }; + let counter = counters.entry(key).or_insert_with(|| { + let builder = self.meter.u64_counter(name.to_string()); + match description { + Some(description) => builder.with_description(description.to_string()).build(), + None => builder.build(), + } + }); counter.add(inc as u64, &attributes); Ok(()) } @@ -242,7 +262,18 @@ impl MetricsClient { /// Send a single counter increment. pub fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) -> Result<()> { - self.0.counter(name, inc, tags) + self.0.counter(name, /*description*/ None, inc, tags) + } + + /// Send a single counter increment with an instrument description. + pub fn counter_with_description( + &self, + name: &str, + description: &str, + inc: i64, + tags: &[(&str, &str)], + ) -> Result<()> { + self.0.counter(name, Some(description), inc, tags) } /// Send a single histogram sample. diff --git a/codex-rs/otel/tests/suite/send.rs b/codex-rs/otel/tests/suite/send.rs index fc382bf88a..3a268d3c7d 100644 --- a/codex-rs/otel/tests/suite/send.rs +++ b/codex-rs/otel/tests/suite/send.rs @@ -13,8 +13,9 @@ fn send_builds_payload_with_tags_and_histograms() -> Result<()> { let (metrics, exporter) = build_metrics_with_defaults(&[("service", "codex-cli"), ("env", "prod")])?; - metrics.counter( + metrics.counter_with_description( "codex.turns", + "Total number of Codex turns.", /*inc*/ 1, &[("model", "gpt-5.1"), ("env", "dev")], )?; @@ -28,6 +29,7 @@ fn send_builds_payload_with_tags_and_histograms() -> Result<()> { let resource_metrics = latest_metrics(&exporter); let counter = find_metric(&resource_metrics, "codex.turns").expect("counter metric missing"); + assert_eq!(counter.description(), "Total number of Codex turns."); let counter_attributes = match counter.data() { opentelemetry_sdk::metrics::data::AggregatedMetrics::U64(data) => match data { opentelemetry_sdk::metrics::data::MetricData::Sum(sum) => { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c9e2e8bb28..b141298288 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -112,6 +112,34 @@ pub struct TurnEnvironmentSelection { pub cwd: AbsolutePathBuf, } +#[derive(Debug, Clone, PartialEq)] +pub struct TurnEnvironmentSelections { + pub legacy_fallback_cwd: AbsolutePathBuf, + pub environments: Vec, +} + +impl TurnEnvironmentSelections { + pub fn new( + legacy_fallback_cwd: AbsolutePathBuf, + environments: Vec, + ) -> Self { + let mut settings = Self { + legacy_fallback_cwd, + environments, + }; + settings.sync_primary_environment_cwd(); + settings + } + + fn sync_primary_environment_cwd(&mut self) { + if let Some(turn_environment) = self.environments.first_mut() + && turn_environment.cwd != self.legacy_fallback_cwd + { + turn_environment.cwd = self.legacy_fallback_cwd.clone(); + } + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "string")] @@ -370,8 +398,8 @@ pub struct ConversationTextParams { /// on their own. #[derive(Debug, Clone, Default, PartialEq)] pub struct ThreadSettingsOverrides { - /// Updated `cwd` for sandbox/tool calls. - pub cwd: Option, + /// Updated fallback `cwd` and environments supplied together as a complete pair. + pub environments: Option, /// Updated runtime workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions. @@ -472,8 +500,6 @@ pub enum Op { UserInput { /// User input items, see `InputItem` items: Vec, - /// Optional turn-scoped environments. - environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. final_output_json_schema: Option, /// Optional turn-scoped Responses API `client_metadata`. @@ -612,7 +638,6 @@ pub enum ThreadMemoryMode { impl From> for Op { fn from(value: Vec) -> Self { Op::UserInput { - environments: None, items: value, final_output_json_schema: None, responsesapi_client_metadata: None, @@ -1324,6 +1349,9 @@ pub enum EventMsg { CollabResumeBegin(CollabResumeBeginEvent), /// Collab interaction: resume end. CollabResumeEnd(CollabResumeEndEvent), + + /// Path-based v2 sub-agent activity. + SubAgentActivity(SubAgentActivityEvent), } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)] @@ -1543,6 +1571,12 @@ impl From for EventMsg { } } +impl From for EventMsg { + fn from(event: SubAgentActivityEvent) -> Self { + EventMsg::SubAgentActivity(event) + } +} + /// Agent lifecycle status, derived from emitted events. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default)] #[serde(rename_all = "snake_case")] @@ -3865,6 +3899,27 @@ pub struct CollabAgentInteractionEndEvent { pub status: AgentStatus, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum SubAgentActivityKind { + Started, + Interacted, + Interrupted, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SubAgentActivityEvent { + pub event_id: String, + #[serde(default)] + pub occurred_at_ms: i64, + /// Thread ID of the affected sub-agent. + pub agent_thread_id: ThreadId, + /// Canonical v2 path of the affected sub-agent. + pub agent_path: AgentPath, + pub kind: SubAgentActivityKind, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct CollabWaitingBeginEvent { #[serde(default)] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a3e1f184ec..bbba245307 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -21,6 +21,7 @@ use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::PatchApplyStatus; +use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::TurnAbortReason; use serde::Serialize; @@ -110,6 +111,7 @@ pub(crate) enum ToolRuntimePayload<'a> { CollabWaitingEnd(&'a codex_protocol::protocol::CollabWaitingEndEvent), CollabCloseBegin(&'a codex_protocol::protocol::CollabCloseBeginEvent), CollabCloseEnd(&'a codex_protocol::protocol::CollabCloseEndEvent), + SubAgentActivity(&'a SubAgentActivityEvent), } impl Serialize for ToolRuntimePayload<'_> { @@ -132,6 +134,7 @@ impl Serialize for ToolRuntimePayload<'_> { ToolRuntimePayload::CollabWaitingEnd(event) => event.serialize(serializer), ToolRuntimePayload::CollabCloseBegin(event) => event.serialize(serializer), ToolRuntimePayload::CollabCloseEnd(event) => event.serialize(serializer), + ToolRuntimePayload::SubAgentActivity(event) => event.serialize(serializer), } } } @@ -215,6 +218,11 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Some(ToolRuntimeTraceEvent::Ended { + tool_call_id: &event.event_id, + status: ExecutionStatus::Completed, + payload: ToolRuntimePayload::SubAgentActivity(event), + }), EventMsg::Error(_) | EventMsg::Warning(_) | EventMsg::GuardianWarning(_) @@ -357,7 +365,8 @@ pub(crate) fn wrapped_protocol_event_type(event: &EventMsg) -> Option<&'static s | EventMsg::CollabCloseBegin(_) | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeBegin(_) - | EventMsg::CollabResumeEnd(_) => None, + | EventMsg::CollabResumeEnd(_) + | EventMsg::SubAgentActivity(_) => None, } } @@ -393,3 +402,7 @@ fn execution_status_for_abort_reason(reason: &TurnAbortReason) -> ExecutionStatu | TurnAbortReason::BudgetLimited => ExecutionStatus::Cancelled, } } + +#[cfg(test)] +#[path = "protocol_event_tests.rs"] +mod tests; diff --git a/codex-rs/rollout-trace/src/protocol_event_tests.rs b/codex-rs/rollout-trace/src/protocol_event_tests.rs new file mode 100644 index 0000000000..b18c7200ac --- /dev/null +++ b/codex-rs/rollout-trace/src/protocol_event_tests.rs @@ -0,0 +1,46 @@ +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SubAgentActivityEvent; +use codex_protocol::protocol::SubAgentActivityKind; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::ToolRuntimeTraceEvent; +use super::tool_runtime_trace_event; +use crate::ExecutionStatus; + +#[test] +fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> { + let agent_thread_id = ThreadId::new(); + let event = EventMsg::SubAgentActivity(SubAgentActivityEvent { + event_id: "call-spawn".to_string(), + occurred_at_ms: 1234, + agent_thread_id, + agent_path: AgentPath::try_from("/root/reviewer").map_err(anyhow::Error::msg)?, + kind: SubAgentActivityKind::Started, + }); + + let Some(ToolRuntimeTraceEvent::Ended { + tool_call_id, + status, + payload, + }) = tool_runtime_trace_event(&event) + else { + panic!("expected terminal tool runtime event"); + }; + + assert_eq!(tool_call_id, "call-spawn"); + assert_eq!(status, ExecutionStatus::Completed); + assert_eq!( + serde_json::to_value(payload)?, + json!({ + "event_id": "call-spawn", + "occurred_at_ms": 1234, + "agent_thread_id": agent_thread_id, + "agent_path": "/root/reviewer", + "kind": "started" + }) + ); + Ok(()) +} diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents.rs b/codex-rs/rollout-trace/src/reducer/tool/agents.rs index 37f3220c2c..656fccaf76 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents.rs @@ -7,6 +7,10 @@ use codex_protocol::protocol::CollabAgentSpawnEndEvent; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::SubAgentActivityEvent; +use codex_protocol::protocol::SubAgentActivityKind; +use serde::Deserialize; +use serde_json::Value; use super::super::TraceReducer; use crate::model::ConversationItem; @@ -52,6 +56,11 @@ pub(in crate::reducer) struct ObservedAgentResultEdge { pub(in crate::reducer) carried_payload: Option, } +#[derive(Deserialize)] +struct AgentMessageInvocationArgs { + message: String, +} + /// Builds the stable edge id for the spawn relationship between two threads. pub(in crate::reducer) fn spawn_edge_id(parent_thread_id: &str, child_thread_id: &str) -> String { format!("edge:spawn:{parent_thread_id}:{child_thread_id}") @@ -123,15 +132,20 @@ impl TraceReducer { runtime_payload: &RawPayloadRef, ) -> Result<()> { let kind = self.rollout.tool_calls[tool_call_id].kind.clone(); + let runtime_payload_json = self.read_payload_json(runtime_payload)?; + if runtime_payload_json.get("agent_thread_id").is_some() { + let payload: SubAgentActivityEvent = serde_json::from_value(runtime_payload_json)?; + return self.end_sub_agent_activity(wall_time_unix_ms, tool_call_id, &kind, &payload); + } match kind { ToolCallKind::SpawnAgent => { let payload: CollabAgentSpawnEndEvent = - serde_json::from_value(self.read_payload_json(runtime_payload)?)?; + serde_json::from_value(runtime_payload_json)?; self.end_spawn_agent_interaction(wall_time_unix_ms, tool_call_id, &payload) } ToolCallKind::AssignAgentTask => { let payload: CollabAgentInteractionEndEvent = - serde_json::from_value(self.read_payload_json(runtime_payload)?)?; + serde_json::from_value(runtime_payload_json)?; self.end_message_agent_interaction( wall_time_unix_ms, tool_call_id, @@ -141,7 +155,7 @@ impl TraceReducer { } ToolCallKind::SendMessage => { let payload: CollabAgentInteractionEndEvent = - serde_json::from_value(self.read_payload_json(runtime_payload)?)?; + serde_json::from_value(runtime_payload_json)?; self.end_message_agent_interaction( wall_time_unix_ms, tool_call_id, @@ -150,8 +164,7 @@ impl TraceReducer { ) } ToolCallKind::CloseAgent => { - let payload: CollabCloseEndEvent = - serde_json::from_value(self.read_payload_json(runtime_payload)?)?; + let payload: CollabCloseEndEvent = serde_json::from_value(runtime_payload_json)?; self.upsert_close_agent_interaction( tool_call_id, payload.receiver_thread_id.to_string(), @@ -169,6 +182,127 @@ impl TraceReducer { } } + fn end_sub_agent_activity( + &mut self, + wall_time_unix_ms: i64, + tool_call_id: &str, + tool_kind: &ToolCallKind, + payload: &SubAgentActivityEvent, + ) -> Result<()> { + let target_thread_id = payload.agent_thread_id.to_string(); + match (tool_kind, &payload.kind) { + (ToolCallKind::SpawnAgent, SubAgentActivityKind::Started) => { + let parent_thread_id = self + .rollout + .tool_calls + .get(tool_call_id) + .with_context(|| { + format!("agent activity referenced unknown tool call {tool_call_id}") + })? + .thread_id + .clone(); + self.queue_sub_agent_activity_message_edge( + wall_time_unix_ms, + tool_call_id, + spawn_edge_id(&parent_thread_id, &target_thread_id), + InteractionEdgeKind::SpawnAgent, + target_thread_id.clone(), + Some(target_thread_id), + ) + } + (ToolCallKind::AssignAgentTask, SubAgentActivityKind::Interacted) => self + .queue_sub_agent_activity_message_edge( + wall_time_unix_ms, + tool_call_id, + tool_edge_id(tool_call_id), + InteractionEdgeKind::AssignAgentTask, + target_thread_id, + /*unresolved_spawn_thread_id*/ None, + ), + (ToolCallKind::SendMessage, SubAgentActivityKind::Interacted) => self + .queue_sub_agent_activity_message_edge( + wall_time_unix_ms, + tool_call_id, + tool_edge_id(tool_call_id), + InteractionEdgeKind::SendMessage, + target_thread_id, + /*unresolved_spawn_thread_id*/ None, + ), + (ToolCallKind::CloseAgent, SubAgentActivityKind::Interrupted) => self + .upsert_close_agent_interaction( + tool_call_id, + target_thread_id, + Some(wall_time_unix_ms), + ), + _ => bail!( + "sub-agent activity {:?} does not match tool call kind {tool_kind:?}", + payload.kind + ), + } + } + + fn queue_sub_agent_activity_message_edge( + &mut self, + wall_time_unix_ms: i64, + tool_call_id: &str, + edge_id: String, + edge_kind: InteractionEdgeKind, + target_thread_id: String, + unresolved_spawn_thread_id: Option, + ) -> Result<()> { + let tool_call = self.rollout.tool_calls.get(tool_call_id).with_context(|| { + format!("agent activity referenced unknown tool call {tool_call_id}") + })?; + let started_at_unix_ms = tool_call.execution.started_at_unix_ms; + let message_content = self.agent_message_content_from_invocation(tool_call_id)?; + let carried_raw_payload_ids = self.agent_tool_payload_ids(tool_call_id)?; + self.queue_or_resolve_agent_interaction_edge(PendingAgentInteractionEdge { + edge_id, + kind: edge_kind, + source: TraceAnchor::ToolCall { + tool_call_id: tool_call_id.to_string(), + }, + target_thread_id, + message_content, + unresolved_spawn_thread_id, + started_at_unix_ms, + ended_at_unix_ms: Some(wall_time_unix_ms), + carried_raw_payload_ids, + }) + } + + fn agent_message_content_from_invocation(&self, tool_call_id: &str) -> Result { + let tool_call = self.rollout.tool_calls.get(tool_call_id).with_context(|| { + format!("agent activity referenced unknown tool call {tool_call_id}") + })?; + let invocation_payload_id = tool_call + .raw_invocation_payload_id + .as_deref() + .with_context(|| { + format!("agent activity tool call {tool_call_id} missing invocation payload") + })?; + let invocation_payload = self + .rollout + .raw_payloads + .get(invocation_payload_id) + .with_context(|| { + format!( + "agent activity tool call {tool_call_id} referenced missing invocation payload {invocation_payload_id}" + ) + })?; + let invocation = self.read_payload_json(invocation_payload)?; + let arguments = invocation + .get("payload") + .and_then(|payload| payload.get("arguments")) + .and_then(Value::as_str) + .with_context(|| { + format!("agent activity tool call {tool_call_id} missing function arguments") + })?; + let args: AgentMessageInvocationArgs = serde_json::from_str(arguments) + .with_context(|| format!("parse agent activity tool call {tool_call_id} arguments"))?; + Ok(args.message) + } + /// Adds the canonical tool result payload to an already reduced multi-agent edge. pub(super) fn attach_agent_interaction_tool_result( &mut self, diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs b/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs index deaf06dac7..b009a15434 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs @@ -194,6 +194,92 @@ fn spawn_runtime_payload_falls_back_to_child_thread_without_delivery_item() -> a Ok(()) } +#[test] +fn sub_agent_started_activity_creates_spawn_edge() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let writer = create_started_agent_writer(&temp)?; + start_agent_turn(&writer, "turn-1")?; + let child_thread_id = "019d0000-0000-7000-8000-000000000002"; + let invocation_payload = writer.write_json_payload( + RawPayloadKind::ToolInvocation, + &json!({ + "tool_name": "spawn_agent", + "payload": { + "type": "function", + "arguments": "{\"message\":\"review this\",\"task_name\":\"reviewer\"}" + } + }), + )?; + writer.append_with_context( + trace_context_for_agent("turn-1"), + RawTraceEventPayload::ToolCallStarted { + tool_call_id: "call-spawn-v2".to_string(), + model_visible_call_id: Some("call-spawn-v2".to_string()), + code_mode_runtime_tool_id: None, + requester: RawToolCallRequester::Model, + kind: ToolCallKind::SpawnAgent, + summary: ToolCallSummary::Generic { + label: "spawn_agent".to_string(), + input_preview: None, + output_preview: None, + }, + invocation_payload: Some(invocation_payload.clone()), + }, + )?; + let activity_payload = writer.write_json_payload( + RawPayloadKind::ToolRuntimeEvent, + &json!({ + "event_id": "call-spawn-v2", + "occurred_at_ms": 1234, + "agent_thread_id": child_thread_id, + "agent_path": "/root/reviewer", + "kind": "started" + }), + )?; + writer.append_with_context( + trace_context_for_agent("turn-1"), + RawTraceEventPayload::ToolCallRuntimeEnded { + tool_call_id: "call-spawn-v2".to_string(), + status: ExecutionStatus::Completed, + runtime_payload: activity_payload.clone(), + }, + )?; + start_thread(&writer, child_thread_id, "/root/reviewer")?; + start_turn_for_thread(&writer, child_thread_id, "turn-child-1")?; + let delivered = inter_agent_message( + "/root", + "/root/reviewer", + "review this", + /*trigger_turn*/ true, + ); + append_inference_request( + &writer, + child_thread_id, + "turn-child-1", + "inference-child-1", + vec![message("assistant", &delivered)], + )?; + + let replayed = replay_bundle(temp.path())?; + let edge_id = format!("edge:spawn:019d0000-0000-7000-8000-000000000001:{child_thread_id}"); + let edge = &replayed.interaction_edges[&edge_id]; + assert_eq!(edge.kind, InteractionEdgeKind::SpawnAgent); + let target_item_id = target_conversation_item_id(&edge.target); + assert_eq!(edge.carried_item_ids, vec![target_item_id.clone()]); + assert_eq!( + replayed.conversation_items[target_item_id].thread_id, + child_thread_id + ); + assert_eq!( + edge.carried_raw_payload_ids, + vec![ + invocation_payload.raw_payload_id, + activity_payload.raw_payload_id, + ] + ); + Ok(()) +} + #[test] fn send_message_runtime_payload_targets_delivered_child_message() -> anyhow::Result<()> { let temp = TempDir::new()?; @@ -300,6 +386,178 @@ fn send_message_runtime_payload_targets_delivered_child_message() -> anyhow::Res Ok(()) } +#[test] +fn send_message_activity_targets_delivered_child_message() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let writer = create_started_agent_writer(&temp)?; + start_agent_turn(&writer, "turn-1")?; + let child_thread_id = "019d0000-0000-7000-8000-000000000002"; + let invocation_payload = writer.write_json_payload( + RawPayloadKind::ToolInvocation, + &json!({ + "tool_name": "send_message", + "payload": { + "type": "function", + "arguments": "{\"target\":\"/root/child\",\"message\":\"hello again\"}" + } + }), + )?; + writer.append_with_context( + trace_context_for_agent("turn-1"), + RawTraceEventPayload::ToolCallStarted { + tool_call_id: "call-send-v2".to_string(), + model_visible_call_id: Some("call-send-v2".to_string()), + code_mode_runtime_tool_id: None, + requester: RawToolCallRequester::Model, + kind: ToolCallKind::SendMessage, + summary: ToolCallSummary::Generic { + label: "send_message".to_string(), + input_preview: None, + output_preview: None, + }, + invocation_payload: Some(invocation_payload.clone()), + }, + )?; + let activity_payload = writer.write_json_payload( + RawPayloadKind::ToolRuntimeEvent, + &json!({ + "event_id": "call-send-v2", + "occurred_at_ms": 1234, + "agent_thread_id": child_thread_id, + "agent_path": "/root/child", + "kind": "interacted" + }), + )?; + writer.append_with_context( + trace_context_for_agent("turn-1"), + RawTraceEventPayload::ToolCallRuntimeEnded { + tool_call_id: "call-send-v2".to_string(), + status: ExecutionStatus::Completed, + runtime_payload: activity_payload.clone(), + }, + )?; + start_thread(&writer, child_thread_id, "/root/child")?; + start_turn_for_thread(&writer, child_thread_id, "turn-child-1")?; + let delivered = inter_agent_message( + "/root", + "/root/child", + "hello again", + /*trigger_turn*/ false, + ); + append_inference_request( + &writer, + child_thread_id, + "turn-child-1", + "inference-child-1", + vec![message("assistant", &delivered)], + )?; + + let replayed = replay_bundle(temp.path())?; + let edge = &replayed.interaction_edges["edge:tool:call-send-v2"]; + assert_eq!(edge.kind, InteractionEdgeKind::SendMessage); + let target_item_id = target_conversation_item_id(&edge.target); + assert_eq!(edge.carried_item_ids, vec![target_item_id.clone()]); + assert_eq!( + replayed.conversation_items[target_item_id].thread_id, + child_thread_id + ); + assert_eq!( + edge.carried_raw_payload_ids, + vec![ + invocation_payload.raw_payload_id, + activity_payload.raw_payload_id, + ] + ); + + Ok(()) +} + +#[test] +fn followup_activity_targets_delivered_child_message() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let writer = create_started_agent_writer(&temp)?; + start_agent_turn(&writer, "turn-1")?; + let child_thread_id = "019d0000-0000-7000-8000-000000000002"; + let invocation_payload = writer.write_json_payload( + RawPayloadKind::ToolInvocation, + &json!({ + "tool_name": "followup_task", + "payload": { + "type": "function", + "arguments": "{\"target\":\"/root/child\",\"message\":\"continue\"}" + } + }), + )?; + writer.append_with_context( + trace_context_for_agent("turn-1"), + RawTraceEventPayload::ToolCallStarted { + tool_call_id: "call-followup-v2".to_string(), + model_visible_call_id: Some("call-followup-v2".to_string()), + code_mode_runtime_tool_id: None, + requester: RawToolCallRequester::Model, + kind: ToolCallKind::AssignAgentTask, + summary: ToolCallSummary::Generic { + label: "followup_task".to_string(), + input_preview: None, + output_preview: None, + }, + invocation_payload: Some(invocation_payload.clone()), + }, + )?; + let activity_payload = writer.write_json_payload( + RawPayloadKind::ToolRuntimeEvent, + &json!({ + "event_id": "call-followup-v2", + "occurred_at_ms": 1234, + "agent_thread_id": child_thread_id, + "agent_path": "/root/child", + "kind": "interacted" + }), + )?; + writer.append_with_context( + trace_context_for_agent("turn-1"), + RawTraceEventPayload::ToolCallRuntimeEnded { + tool_call_id: "call-followup-v2".to_string(), + status: ExecutionStatus::Completed, + runtime_payload: activity_payload.clone(), + }, + )?; + start_thread(&writer, child_thread_id, "/root/child")?; + start_turn_for_thread(&writer, child_thread_id, "turn-child-1")?; + let delivered = inter_agent_message( + "/root", + "/root/child", + "continue", + /*trigger_turn*/ true, + ); + append_inference_request( + &writer, + child_thread_id, + "turn-child-1", + "inference-child-1", + vec![message("assistant", &delivered)], + )?; + + let replayed = replay_bundle(temp.path())?; + let edge = &replayed.interaction_edges["edge:tool:call-followup-v2"]; + assert_eq!(edge.kind, InteractionEdgeKind::AssignAgentTask); + let target_item_id = target_conversation_item_id(&edge.target); + assert_eq!(edge.carried_item_ids, vec![target_item_id.clone()]); + assert_eq!( + replayed.conversation_items[target_item_id].thread_id, + child_thread_id + ); + assert_eq!( + edge.carried_raw_payload_ids, + vec![ + invocation_payload.raw_payload_id, + activity_payload.raw_payload_id, + ] + ); + + Ok(()) +} + #[test] fn close_agent_runtime_payload_targets_thread() -> anyhow::Result<()> { let temp = TempDir::new()?; diff --git a/codex-rs/rollout-trace/src/tool_dispatch.rs b/codex-rs/rollout-trace/src/tool_dispatch.rs index 7082283b47..36c6651b10 100644 --- a/codex-rs/rollout-trace/src/tool_dispatch.rs +++ b/codex-rs/rollout-trace/src/tool_dispatch.rs @@ -269,7 +269,7 @@ fn dispatched_tool_kind(tool_name: &str, _payload: &ToolDispatchPayload) -> Tool "send_message" => ToolCallKind::SendMessage, "followup_task" | "assign_task" => ToolCallKind::AssignAgentTask, "wait_agent" => ToolCallKind::WaitAgent, - "close_agent" => ToolCallKind::CloseAgent, + "close_agent" | "interrupt_agent" => ToolCallKind::CloseAgent, other => ToolCallKind::Other { name: other.to_string(), }, @@ -425,6 +425,19 @@ mod tests { ))); } + #[test] + fn classifies_interrupt_agent_as_close_agent() { + assert_eq!( + dispatched_tool_kind( + "interrupt_agent", + &ToolDispatchPayload::Function { + arguments: r#"{"target":"/root/child"}"#.to_string(), + }, + ), + ToolCallKind::CloseAgent + ); + } + fn invocation( tool_name: &str, tool_namespace: Option, diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 17da9e6203..9760191bd4 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -91,7 +91,8 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::TurnStarted(_) | EventMsg::TurnComplete(_) | EventMsg::WebSearchEnd(_) - | EventMsg::ImageGenerationEnd(_) => true, + | EventMsg::ImageGenerationEnd(_) + | EventMsg::SubAgentActivity(_) => true, EventMsg::ItemCompleted(event) => { // Plan items are derived from streaming tags and are not part of the // raw ResponseItem history, so we persist their completion to replay diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs index 0ff3f6977f..819fc9365e 100644 --- a/codex-rs/sandboxing/src/landlock.rs +++ b/codex-rs/sandboxing/src/landlock.rs @@ -47,7 +47,8 @@ pub fn create_linux_sandbox_command_args_for_permission_profile( "--permission-profile".to_string(), permission_profile_json, ]; - if use_legacy_landlock { + // Proxy-only networking requires bubblewrap's isolated network namespace. + if use_legacy_landlock && !allow_network_for_proxy { linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { @@ -83,7 +84,8 @@ fn create_linux_sandbox_command_args( "--command-cwd".to_string(), command_cwd, ]; - if use_legacy_landlock { + // Proxy-only networking requires bubblewrap's isolated network namespace. + if use_legacy_landlock && !allow_network_for_proxy { linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { diff --git a/codex-rs/sandboxing/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs index 14b1c047eb..d0bbab8400 100644 --- a/codex-rs/sandboxing/src/landlock_tests.rs +++ b/codex-rs/sandboxing/src/landlock_tests.rs @@ -33,14 +33,16 @@ fn legacy_landlock_flag_is_included_when_requested() { } #[test] -fn proxy_flag_is_included_when_requested() { +fn proxy_flag_takes_precedence_over_legacy_landlock() { let command = vec!["/bin/true".to_string()]; let command_cwd = Path::new("/tmp/link"); let cwd = Path::new("/tmp"); + let permission_profile = PermissionProfile::read_only(); - let args = create_linux_sandbox_command_args( + let args = create_linux_sandbox_command_args_for_permission_profile( command, command_cwd, + &permission_profile, cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ true, @@ -49,6 +51,7 @@ fn proxy_flag_is_included_when_requested() { args.contains(&"--allow-network-for-proxy".to_string()), true ); + assert_eq!(args.contains(&"--use-legacy-landlock".to_string()), false); } #[test] diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 39ef358f8c..682e8e3dcd 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -338,8 +338,8 @@ fn ensure_linux_bubblewrap_is_supported( allow_network_for_proxy: bool, is_wsl1: bool, ) -> Result<(), SandboxTransformError> { - let requires_bubblewrap = !use_legacy_landlock - && (!file_system_sandbox_policy.has_full_disk_write_access() || allow_network_for_proxy); + let requires_bubblewrap = allow_network_for_proxy + || (!use_legacy_landlock && !file_system_sandbox_policy.has_full_disk_write_access()); if is_wsl1 && requires_bubblewrap { return Err(SandboxTransformError::Wsl1UnsupportedForBubblewrap); } diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index fede00fe1e..58138ad775 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -342,6 +342,15 @@ fn wsl1_rejects_linux_bubblewrap_path() { ), Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap) )); + assert!(matches!( + super::ensure_linux_bubblewrap_is_supported( + &FileSystemSandboxPolicy::unrestricted(), + /*use_legacy_landlock*/ true, + /*allow_network_for_proxy*/ true, + /*is_wsl1*/ true, + ), + Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap) + )); } #[cfg(target_os = "linux")] diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 47e0fc3911..97bfea8280 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -238,6 +238,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R codex_home, history: History::default(), ephemeral: true, + extra_config: None, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: arg0_paths.codex_self_exe, codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, @@ -296,7 +297,6 @@ async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyh text: prompt, text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -327,6 +327,7 @@ async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyh | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeBegin(_) | EventMsg::CollabResumeEnd(_) + | EventMsg::SubAgentActivity(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::PlanDelta(_) | EventMsg::ReasoningContentDelta(_) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 3a44c26b32..edc308a905 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -356,6 +356,7 @@ fn stored_thread_from_state( Ok(StoredThread { thread_id, + extra_config: created.extra_config.clone(), rollout_path: metadata .and_then(|metadata| metadata.rollout_path.clone()) .or(rollout_path), diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index 157d13004f..84b72f23f0 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -25,6 +25,7 @@ pub use types::AppendThreadItemsParams; pub use types::ArchiveThreadParams; pub use types::ClearableField; pub use types::CreateThreadParams; +pub use types::ExtraConfig; pub use types::GitInfoPatch; pub use types::ItemPage; pub use types::ListItemsParams; diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index ae0650855a..5acdcf09b6 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -122,6 +122,7 @@ pub(super) fn stored_thread_from_rollout_item( Some(StoredThread { thread_id, + extra_config: None, rollout_path: Some(rollout_path), forked_from_id: None, parent_thread_id: item.parent_thread_id, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 146bfbd843..cef4496a02 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1013,6 +1013,7 @@ mod tests { fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 52c8eb7a38..24bc32993c 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -325,6 +325,7 @@ async fn stored_thread_from_sqlite_metadata( permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path()); StoredThread { thread_id: metadata.id, + extra_config: None, rollout_path: Some(rollout_path), forked_from_id, parent_thread_id, @@ -390,6 +391,7 @@ fn stored_thread_from_meta_line( let rollout_path = codex_rollout::plain_rollout_path(path.as_path()); StoredThread { thread_id: meta_line.meta.id, + extra_config: None, rollout_path: Some(rollout_path), forked_from_id: meta_line.meta.forked_from_id, parent_thread_id: meta_line.meta.parent_thread_id, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 1ab42a3117..1f18adf081 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -56,11 +56,17 @@ pub struct ThreadPersistenceMetadata { pub memory_mode: MemoryMode, } +/// Extra configuration fields for a thread. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtraConfig {} + /// Parameters required to create a persisted thread. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateThreadParams { /// Thread id generated by Codex before opening persistence. pub thread_id: ThreadId, + /// Optional extra configuration fields for the thread. + pub extra_config: Option, /// Source thread id when this thread is created as a fork. pub forked_from_id: Option, /// The ID of the parent thread. This will only be set if this thread is a subagent. @@ -349,6 +355,8 @@ pub struct ItemPage { pub struct StoredThread { /// Thread id. pub thread_id: ThreadId, + /// Optional extra configuration fields for the thread. + pub extra_config: Option, /// Local rollout path when the backing store is filesystem-based. pub rollout_path: Option, /// Source thread id when this thread was forked from another thread. diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 5352eaa91d..acffb78b11 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -6,13 +6,15 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; const DEFINITION_TABLE_KEYS: [&str; 2] = ["$defs", "definitions"]; -const SCHEMA_CHILD_KEYS: [&str; 2] = ["items", "anyOf"]; +const SCHEMA_CHILD_KEYS: [&str; 4] = ["items", "anyOf", "oneOf", "allOf"]; +const COMPOSITION_SCHEMA_KEYS: [&str; 3] = ["anyOf", "oneOf", "allOf"]; /// Primitive JSON Schema type names we support in tool definitions. /// /// This mirrors the OpenAI Structured Outputs subset for JSON Schema `type`: /// string, number, boolean, integer, object, array, and null. -/// Keywords such as `enum`, `const`, and `anyOf` are modeled separately. +/// Keywords such as `enum`, `const`, `anyOf`, `oneOf`, and `allOf` are modeled +/// separately. /// See . #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -61,6 +63,10 @@ pub struct JsonSchema { pub additional_properties: Option, #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")] pub any_of: Option>, + #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")] + pub one_of: Option>, + #[serde(rename = "allOf", skip_serializing_if = "Option::is_none")] + pub all_of: Option>, #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")] pub defs: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -85,6 +91,22 @@ impl JsonSchema { } } + pub fn one_of(variants: Vec, description: Option) -> Self { + Self { + description, + one_of: Some(variants), + ..Default::default() + } + } + + pub fn all_of(variants: Vec, description: Option) -> Self { + Self { + description, + all_of: Some(variants), + ..Default::default() + } + } + pub fn boolean(description: Option) -> Self { Self::typed(JsonSchemaPrimitiveType::Boolean, description) } @@ -198,7 +220,7 @@ fn deserialize_tool_input_schema(input_schema: JsonValue) -> Result { + for value in values { + prune_schema_compositions(value); + } + } + JsonValue::Object(map) => { + if has_composition_keyword(map) { + *value = json!({}); + return; + } + + for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| { + prune_schema_compositions(value); + }); + } + _ => {} + } +} + fn is_complex_schema_object(map: &serde_json::Map) -> bool { SCHEMA_CHILD_KEYS.iter().any(|key| map.contains_key(*key)) || map.contains_key("properties") @@ -404,10 +448,16 @@ fn is_complex_schema_object(map: &serde_json::Map) -> bool { || map.contains_key("$ref") } +fn has_composition_keyword(map: &serde_json::Map) -> bool { + COMPOSITION_SCHEMA_KEYS + .into_iter() + .any(|key| map.contains_key(key)) +} + /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// schema representation. This function: /// - Ensures every typed schema object has a `"type"` when required. -/// - Preserves explicit `anyOf`. +/// - Preserves explicit `anyOf`, `oneOf`, and `allOf`. /// - Preserves `$ref` and reachable local `$defs` / `definitions`. /// - Collapses `const` into single-value `enum`. /// - Fills required child fields for object/array schema types, including @@ -443,8 +493,10 @@ fn sanitize_json_schema(value: &mut JsonValue) { if let Some(value) = map.get_mut("prefixItems") { sanitize_json_schema(value); } - if let Some(value) = map.get_mut("anyOf") { - sanitize_json_schema(value); + for key in COMPOSITION_SCHEMA_KEYS { + if let Some(value) = map.get_mut(key) { + sanitize_json_schema(value); + } } for table in DEFINITION_TABLE_KEYS { sanitize_schema_table(map, table); @@ -456,7 +508,8 @@ fn sanitize_json_schema(value: &mut JsonValue) { let mut schema_types = normalized_schema_types(map); - if schema_types.is_empty() && (map.contains_key("$ref") || map.contains_key("anyOf")) { + if schema_types.is_empty() && (map.contains_key("$ref") || has_composition_keyword(map)) + { return; } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 6973d06c00..3011c54f93 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -728,6 +728,109 @@ fn parse_tool_input_schema_preserves_nested_any_of_property() { ); } +#[test] +fn parse_tool_input_schema_preserves_nested_one_of_property() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "query": { + // "oneOf": [ + // { "const": "exact" }, + // { "type": "number" } + // ] + // } + // } + // } + // + // Expected normalization behavior: + // - The nested `oneOf` is preserved. + // - Child variants are recursively sanitized, including `const` to `enum`. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "query": { + "oneOf": [ + { "const": "exact" }, + { "type": "number" } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::object( + BTreeMap::from([( + "query".to_string(), + JsonSchema::one_of( + vec![ + JsonSchema::string_enum( + vec![serde_json::json!("exact")], + /*description*/ None, + ), + JsonSchema::number(/*description*/ None), + ], + /*description*/ None, + ), + )]), + /*required*/ None, + /*additional_properties*/ None + ) + ); +} + +#[test] +fn parse_tool_input_schema_preserves_nested_all_of_property() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "query": { + // "allOf": [ + // { "type": "string" }, + // { "description": "unrecognized by itself" } + // ] + // } + // } + // } + // + // Expected normalization behavior: + // - The nested `allOf` is preserved structurally rather than flattened. + // - Child variants are recursively sanitized. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "query": { + "allOf": [ + { "type": "string" }, + { "description": "unrecognized by itself" } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::object( + BTreeMap::from([( + "query".to_string(), + JsonSchema::all_of( + vec![ + JsonSchema::string(/*description*/ None), + JsonSchema::default(), + ], + /*description*/ None, + ), + )]), + /*required*/ None, + /*additional_properties*/ None + ) + ); +} + #[test] fn parse_tool_input_schema_preserves_type_unions_without_rewriting_to_any_of() { // Example schema shape: @@ -1070,6 +1173,77 @@ fn parse_large_tool_input_schema_strips_descriptions_without_removing_descriptio ); } +#[test] +fn parse_large_tool_input_schema_prunes_compositions_as_last_resort() { + for composition_key in super::COMPOSITION_SCHEMA_KEYS { + let variants = vec![ + serde_json::json!({ + "type": "string", + "enum": ["first ".repeat(400)] + }), + serde_json::json!({ + "type": "string", + "enum": ["second ".repeat(400)] + }), + serde_json::json!({ + "type": "string", + "enum": ["third ".repeat(400)] + }), + ]; + + let mut choice = serde_json::Map::new(); + choice.insert( + composition_key.to_string(), + serde_json::Value::Array(variants), + ); + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "choice": choice + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": {} + } + }) + ); + } +} + +#[test] +fn parse_large_tool_input_schema_prunes_single_composition_variant_if_still_over_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "anyOf": [ + { + "type": "string", + "enum": ["x".repeat(4_500)] + } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": {} + } + }) + ); +} + #[test] fn parse_large_tool_input_schema_preserves_object_enum_literal_descriptions() { let schema = parse_tool_input_schema(&serde_json::json!({ @@ -1126,7 +1300,12 @@ fn collapse_deep_schema_objects_traverses_schema_children() { "complex": { "type": "object", "properties": { - "leaf": { "type": "string" } + "nested": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } } }, "scalar": { @@ -1139,7 +1318,12 @@ fn collapse_deep_schema_objects_traverses_schema_children() { "items": { "type": "object", "properties": { - "leaf": { "type": "string" } + "nested": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } } } }, @@ -1148,7 +1332,12 @@ fn collapse_deep_schema_objects_traverses_schema_children() { "additionalProperties": { "type": "object", "properties": { - "leaf": { "type": "string" } + "nested": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } } } }, @@ -1157,7 +1346,12 @@ fn collapse_deep_schema_objects_traverses_schema_children() { { "type": "object", "properties": { - "leaf": { "type": "string" } + "nested": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } } }, { "type": "string" } @@ -1176,7 +1370,12 @@ fn collapse_deep_schema_objects_traverses_schema_children() { "object_parent": { "type": "object", "properties": { - "complex": {}, + "complex": { + "type": "object", + "properties": { + "nested": {} + } + }, "scalar": { "type": "string" } @@ -1184,15 +1383,30 @@ fn collapse_deep_schema_objects_traverses_schema_children() { }, "array_parent": { "type": "array", - "items": {} + "items": { + "type": "object", + "properties": { + "nested": {} + } + } }, "map_parent": { "type": "object", - "additionalProperties": {} + "additionalProperties": { + "type": "object", + "properties": { + "nested": {} + } + } }, "union_parent": { "anyOf": [ - {}, + { + "type": "object", + "properties": { + "nested": {} + } + }, { "type": "string" } ] } @@ -1397,10 +1611,24 @@ fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { {"$ref": "#/$defs/Choice"}, {"type": "string"} ] + }, + "exclusive_choice": { + "oneOf": [ + {"$ref": "#/$defs/ExclusiveChoice"}, + {"type": "integer"} + ] + }, + "combined": { + "allOf": [ + {"$ref": "#/$defs/Combined"}, + {"type": "object"} + ] } }, "$defs": { + "Combined": {"type": "object"}, "Choice": {"type": "boolean"}, + "ExclusiveChoice": {"type": "null"}, "Extra": {"type": "number"}, "Item": {"type": "string"}, "Unused": {"type": "null"} @@ -1419,6 +1647,18 @@ fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { {"type": "string"} ] }, + "combined": { + "allOf": [ + {"$ref": "#/$defs/Combined"}, + {"type": "object", "properties": {}} + ] + }, + "exclusive_choice": { + "oneOf": [ + {"$ref": "#/$defs/ExclusiveChoice"}, + {"type": "integer"} + ] + }, "items_holder": { "type": "array", "items": {"$ref": "#/$defs/Item"} @@ -1430,7 +1670,9 @@ fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { } }, "$defs": { + "Combined": {"type": "object", "properties": {}}, "Choice": {"type": "boolean"}, + "ExclusiveChoice": {"type": "null"}, "Extra": {"type": "number"}, "Item": {"type": "string"} } diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json index bf37296cc0..518038aee5 100644 --- a/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json @@ -53,13 +53,18 @@ { "pointer": "/properties/title/description", "value": "Copied file title." + }, + { + "pointer": "/properties/file/oneOf/0/type", + "value": "string" + }, + { + "pointer": "/properties/metadata/allOf/0/properties/source/type", + "value": "string" } ], "expected_pruned": [], - "expected_dropped_fields": [ - "/properties/file/oneOf", - "/properties/metadata/allOf" - ] + "expected_dropped_fields": [] } ] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5586dfa5b1..02ba320786 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -60,6 +60,7 @@ use crate::multi_agents::agent_picker_status_dot_spans; use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; +use crate::multi_agents::sub_agent_activity_display; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -128,6 +129,7 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WriteStatus; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::types::ApprovalsReviewer; @@ -195,6 +197,7 @@ use toml::Value as TomlValue; use uuid::Uuid; mod agent_message_consolidation; mod agent_navigation; +mod agent_status_feed; mod app_server_event_targets; mod app_server_events; pub(crate) mod app_server_requests; @@ -264,6 +267,20 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str } } +fn sub_agent_activity_item(notification: &ServerNotification) -> Option<&ThreadItem> { + match notification { + ServerNotification::ItemStarted(notification) => match ¬ification.item { + ThreadItem::SubAgentActivity { .. } => Some(¬ification.item), + _ => None, + }, + ServerNotification::ItemCompleted(notification) => match ¬ification.item { + ThreadItem::SubAgentActivity { .. } => Some(¬ification.item), + _ => None, + }, + _ => None, + } +} + fn collab_receiver_is_not_found( notification: &ServerNotification, receiver_thread_id: &str, @@ -488,6 +505,7 @@ pub(crate) struct App { cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, runtime_approval_policy_override: Option, runtime_permission_profile_override: Option, @@ -718,6 +736,7 @@ impl App { cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, initial_prompt: Option, initial_images: Vec, session_selection: SessionSelection, @@ -964,6 +983,7 @@ See the Codex keymap documentation for supported actions and examples." cli_kv_overrides, harness_overrides, loader_overrides, + cloud_config_bundle, runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, diff --git a/codex-rs/tui/src/app/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs index db9e47c5bc..e329de90ab 100644 --- a/codex-rs/tui/src/app/agent_navigation.rs +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -19,6 +19,7 @@ //! updated or marked closed. use crate::multi_agents::AgentPickerThreadEntry; +use crate::multi_agents::SubAgentActivityDisplay; use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut; use crate::multi_agents::previous_agent_shortcut; @@ -86,16 +87,56 @@ impl AgentNavigationState { if !self.threads.contains_key(&thread_id) { self.order.push(thread_id); } + let (previous_agent_path, previous_is_running) = self + .threads + .get(&thread_id) + .map(|entry| (entry.agent_path.clone(), entry.is_running)) + .unwrap_or((None, false)); self.threads.insert( thread_id, AgentPickerThreadEntry { agent_nickname, agent_role, + agent_path: previous_agent_path, + is_running: previous_is_running && !is_closed, is_closed, }, ); } + pub(crate) fn record_sub_agent_activity(&mut self, activity: SubAgentActivityDisplay) { + if !self.threads.contains_key(&activity.thread_id) { + self.order.push(activity.thread_id); + } + let entry = + self.threads + .entry(activity.thread_id) + .or_insert_with(|| AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + agent_path: None, + is_running: false, + is_closed: false, + }); + entry.agent_path = Some(activity.agent_path); + entry.is_running = activity.is_running_hint; + entry.is_closed = false; + } + + pub(crate) fn set_running(&mut self, thread_id: ThreadId, is_running: bool) { + if let Some(entry) = self.threads.get_mut(&thread_id) { + entry.is_running = is_running; + } + } + + pub(crate) fn set_agent_path(&mut self, thread_id: ThreadId, agent_path: Option) { + if let Some(agent_path) = agent_path + && let Some(entry) = self.threads.get_mut(&thread_id) + { + entry.agent_path = Some(agent_path); + } + } + /// Marks a thread as closed without removing it from the traversal cache. /// /// Closed threads stay in the picker and in spawn order so users can still review them and so @@ -105,6 +146,7 @@ impl AgentNavigationState { pub(crate) fn mark_closed(&mut self, thread_id: ThreadId) { if let Some(entry) = self.threads.get_mut(&thread_id) { entry.is_closed = true; + entry.is_running = false; } else { self.upsert( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, @@ -155,6 +197,22 @@ impl AgentNavigationState { .collect() } + pub(crate) fn ordered_path_backed_subagent_threads( + &self, + primary_thread_id: Option, + ) -> Vec<(ThreadId, &AgentPickerThreadEntry)> { + self.ordered_threads() + .into_iter() + .filter(|(thread_id, entry)| { + Some(*thread_id) != primary_thread_id + && entry + .agent_path + .as_deref() + .is_some_and(|agent_path| !agent_path.trim().is_empty()) + }) + .collect() + } + /// Returns tracked thread ids in the same stable order used by the picker. pub(crate) fn tracked_thread_ids(&self) -> Vec { self.ordered_threads() @@ -217,6 +275,14 @@ impl AgentNavigationState { self.threads .get(&thread_id) .map(|entry| { + if !is_primary + && let Some(agent_path) = entry + .agent_path + .as_deref() + .filter(|agent_path| !agent_path.trim().is_empty()) + { + return format!("`{agent_path}`"); + } format_agent_picker_item_name( entry.agent_nickname.as_deref(), entry.agent_role.as_deref(), diff --git a/codex-rs/tui/src/app/agent_status_feed.rs b/codex-rs/tui/src/app/agent_status_feed.rs new file mode 100644 index 0000000000..baeb2d0807 --- /dev/null +++ b/codex-rs/tui/src/app/agent_status_feed.rs @@ -0,0 +1,209 @@ +//! Bounded, best-effort previews for the v2 `/agent` status output. + +use super::ThreadBufferedEvent; +use super::ThreadEventStore; +use crate::history_cell::HistoryCell; +use crate::history_cell::plain_lines; +use crate::text_formatting::truncate_text; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SubAgentActivityKind; +use codex_app_server_protocol::ThreadItem; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::collections::HashSet; + +const AGENT_STATUS_PREVIEW_LINES: usize = 3; +const AGENT_STATUS_PREVIEW_ITEMS: usize = 6; +const AGENT_STATUS_PREVIEW_GRAPHEMES: usize = 240; +const AGENT_STATUS_PREVIEW_INDENT: u16 = 4; + +#[derive(Debug)] +pub(super) struct AgentStatusHistoryCell { + entries: Vec, +} + +impl AgentStatusHistoryCell { + pub(super) fn new(entries: Vec) -> Self { + Self { entries } + } +} + +impl HistoryCell for AgentStatusHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = vec![ + "/agent".magenta().into(), + "Sub-agents running".bold().into(), + "".into(), + ]; + + if self.entries.is_empty() { + lines.push(" • No sub-agents running.".italic().into()); + return lines; + } + + for entry in &self.entries { + lines.push(entry.title_line()); + let preview_width = width.saturating_sub(AGENT_STATUS_PREVIEW_INDENT).max(1); + let preview_lines = entry.preview_lines(preview_width); + if preview_lines.is_empty() { + lines.push(vec![" ".into(), "No recent activity yet.".dim().italic()].into()); + } else { + lines.extend(preview_lines.into_iter().map(indent_preview_line)); + } + lines.push("".into()); + } + let _ = lines.pop(); + lines + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } +} + +#[derive(Debug)] +pub(super) struct AgentStatusThreadPreview { + agent_path: String, + activity: Vec, +} + +impl AgentStatusThreadPreview { + pub(super) fn from_store(agent_path: String, store: &ThreadEventStore) -> Self { + Self::from_events(agent_path, store.buffer.iter().rev()) + } + + pub(super) fn empty(agent_path: String) -> Self { + Self::from_events(agent_path, std::iter::empty()) + } + + fn from_events<'a>( + agent_path: String, + events: impl Iterator, + ) -> Self { + let mut seen_item_ids = HashSet::new(); + let mut activity = Vec::new(); + for event in events { + let item = match event { + ThreadBufferedEvent::Notification(ServerNotification::ItemCompleted(event)) => { + &event.item + } + ThreadBufferedEvent::Notification(ServerNotification::ItemStarted(event)) => { + &event.item + } + ThreadBufferedEvent::Notification(_) + | ThreadBufferedEvent::Request(_) + | ThreadBufferedEvent::HistoryEntryResponse(_) + | ThreadBufferedEvent::FeedbackSubmission(_) => continue, + }; + if !seen_item_ids.insert(item.id().to_string()) { + continue; + } + if let Some(summary) = activity_summary(item) { + activity.push(summary); + if activity.len() == AGENT_STATUS_PREVIEW_ITEMS { + break; + } + } + } + activity.reverse(); + Self { + agent_path, + activity, + } + } + + fn title_line(&self) -> Line<'static> { + vec![" • ".dim(), format!("`{}`", self.agent_path).cyan()].into() + } + + fn preview_lines(&self, width: u16) -> Vec> { + let mut lines = self + .activity + .iter() + .flat_map(|activity| textwrap::wrap(activity, width as usize)) + .filter(|line| !line.trim().is_empty()) + .map(|line| line.into_owned().dim().into()) + .collect::>(); + if lines.len() > AGENT_STATUS_PREVIEW_LINES { + lines.drain(..lines.len() - AGENT_STATUS_PREVIEW_LINES); + } + lines + } +} + +fn activity_summary(item: &ThreadItem) -> Option { + let summary = match item { + ThreadItem::AgentMessage { text, .. } | ThreadItem::Plan { text, .. } => text, + ThreadItem::Reasoning { summary, .. } => summary.last()?, + ThreadItem::CommandExecution { command, .. } => { + let command = truncate_text( + command, + AGENT_STATUS_PREVIEW_GRAPHEMES.saturating_sub("$ ".len()), + ); + return bounded_summary(&format!("$ {command}")); + } + ThreadItem::FileChange { changes, .. } => { + return bounded_summary(&format!("Updated {} file(s)", changes.len())); + } + ThreadItem::McpToolCall { server, tool, .. } => { + return bounded_summary(&format!("MCP {server}/{tool}")); + } + ThreadItem::DynamicToolCall { + namespace, tool, .. + } => { + let tool = namespace + .as_ref() + .map(|namespace| format!("{namespace}/{tool}")) + .unwrap_or_else(|| tool.clone()); + return bounded_summary(&format!("Tool {tool}")); + } + ThreadItem::CollabAgentToolCall { tool, .. } => { + let action = match tool { + CollabAgentTool::SpawnAgent => "Spawned an agent", + CollabAgentTool::SendInput => "Sent input to an agent", + CollabAgentTool::ResumeAgent => "Resumed an agent", + CollabAgentTool::Wait => "Waited for an agent", + CollabAgentTool::CloseAgent => "Closed an agent", + }; + return Some(action.to_string()); + } + ThreadItem::SubAgentActivity { + kind, agent_path, .. + } => { + let action = match kind { + SubAgentActivityKind::Started => "Started", + SubAgentActivityKind::Interacted => "Contacted", + SubAgentActivityKind::Interrupted => "Interrupted", + }; + return bounded_summary(&format!("{action} {agent_path}")); + } + ThreadItem::WebSearch { query, .. } => { + return bounded_summary(&format!("Web search: {query}")); + } + ThreadItem::ImageView { path, .. } => { + return bounded_summary(&format!("Viewed {}", path.display())); + } + ThreadItem::ImageGeneration { .. } => return Some("Generated an image".to_string()), + ThreadItem::EnteredReviewMode { .. } => return Some("Entered review mode".to_string()), + ThreadItem::ExitedReviewMode { .. } => return Some("Exited review mode".to_string()), + ThreadItem::ContextCompaction { .. } => return Some("Compacted context".to_string()), + ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } => return None, + }; + bounded_summary(summary) +} + +fn bounded_summary(summary: &str) -> Option { + let summary = truncate_text(summary, AGENT_STATUS_PREVIEW_GRAPHEMES); + let summary = summary.split_whitespace().collect::>().join(" "); + (!summary.is_empty()).then_some(summary) +} + +fn indent_preview_line(mut line: Line<'static>) -> Line<'static> { + line.spans.insert(0, " ".into()); + line +} + +#[cfg(test)] +#[path = "agent_status_feed_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs new file mode 100644 index 0000000000..5b8bc4b59b --- /dev/null +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -0,0 +1,109 @@ +use super::*; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_utils_absolute_path::AbsolutePathBuf; + +#[test] +fn agent_status_uses_bounded_buffered_activity() { + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.push_notification(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::CommandExecution { + id: "command-1".to_string(), + command: "cargo test -p codex-tui".to_string(), + cwd: AbsolutePathBuf::try_from("/workspace").expect("absolute path"), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::Completed, + command_actions: Vec::new(), + aggregated_output: Some("unbounded output\n".repeat(10_000)), + exit_code: Some(0), + duration_ms: Some(42), + }, + thread_id: "thread-child".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 1, + }, + )); + store.push_notification(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "message-1".to_string(), + text: "Finished checking the focused TUI tests.".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-child".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 2, + }, + )); + + let preview = AgentStatusThreadPreview::from_store("/root/reviewer".to_string(), &store); + let cell = AgentStatusHistoryCell::new(vec![preview]); + let rendered = cell + .display_lines(/*width*/ 80) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r###" + /agent + Sub-agents running + + • `/root/reviewer` + $ cargo test -p codex-tui + Finished checking the focused TUI tests. + "###); + assert!(!rendered.contains("unbounded output")); +} + +#[test] +fn agent_status_uses_reasoning_summaries_only() { + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.push_notification(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-with-summary".to_string(), + summary: vec!["safe summary".to_string()], + content: vec!["hidden raw reasoning".to_string()], + }, + thread_id: "thread-child".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 1, + }, + )); + store.push_notification(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-without-summary".to_string(), + summary: Vec::new(), + content: vec!["raw-only reasoning".to_string()], + }, + thread_id: "thread-child".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 2, + }, + )); + + let preview = AgentStatusThreadPreview::from_store("/root/reviewer".to_string(), &store); + let cell = AgentStatusHistoryCell::new(vec![preview]); + let rendered = cell + .display_lines(/*width*/ 80) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r###" + /agent + Sub-agents running + + • `/root/reviewer` + safe summary + "###); + assert!(!rendered.contains("hidden raw reasoning")); + assert!(!rendered.contains("raw-only reasoning")); +} diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 319830b82a..7d1f6047e0 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -34,7 +34,8 @@ impl App { .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()); + .loader_overrides(self.loader_overrides.clone()) + .cloud_config_bundle(self.cloud_config_bundle.clone()); build_config_on_runtime_worker( builder, format!("Failed to rebuild config for cwd {cwd_display}"), @@ -55,7 +56,8 @@ impl App { .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()); + .loader_overrides(self.loader_overrides.clone()) + .cloud_config_bundle(self.cloud_config_bundle.clone()); build_config_on_runtime_worker( builder, format!("Failed to rebuild config for permission profile {profile_id}"), @@ -1123,6 +1125,66 @@ mod tests { Ok(()) } + // Regression coverage for `/new` and `/clear`: cloud requirements + // must survive the config refresh that runs before thread transitions. + #[tokio::test] + async fn refresh_in_memory_config_from_disk_keeps_cloud_requirements_for_thread_transitions() + -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + let required_policy = codex_protocol::protocol::AskForApproval::Never; + let cloud_config_bundle = + codex_config::test_support::CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cloud_config_bundle(cloud_config_bundle.clone()) + .build() + .await?; + app.config = config; + app.cloud_config_bundle = cloud_config_bundle; + let app_id = "unit_test_cloud_requirements_reload_marker"; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +[apps.{app_id}] +enabled = false +"# + ), + )?; + + let assert_cloud_requirements = |app: &App| { + let config = app.fresh_session_config(); + assert_eq!( + config + .config_layer_stack + .requirements_toml() + .allowed_approval_policies + .clone(), + Some(vec![required_policy]) + ); + assert_eq!(config.permissions.approval_policy.value(), required_policy); + }; + + assert_cloud_requirements(&app); + assert_eq!(app_enabled_in_effective_config(&app.config, app_id), None); + + // This is the fallible reload that the best-effort `/new`, `/clear`, + // `/fork`, side-conversation, and session-picker paths wrap. + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!( + app_enabled_in_effective_config(&app.config, app_id), + Some(false) + ); + assert_cloud_requirements(&app); + Ok(()) + } + #[tokio::test] async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> { diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index d988c80516..aba08f08d9 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -14,8 +14,10 @@ //! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are //! found. The primary thread itself is never included in the output. +use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; +use codex_protocol::protocol::SubAgentSource; use std::collections::HashMap; use std::collections::HashSet; @@ -26,6 +28,7 @@ pub(crate) struct LoadedSubagentThread { pub(crate) thread_id: ThreadId, pub(crate) agent_nickname: Option, pub(crate) agent_role: Option, + pub(crate) agent_path: Option, } /// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent. @@ -84,6 +87,7 @@ pub(crate) fn find_loaded_subagent_threads_for_primary( thread_id, agent_nickname: thread.agent_nickname, agent_role: thread.agent_role, + agent_path: thread_spawn_agent_path(&thread.source), }) }) .collect(); @@ -91,16 +95,22 @@ pub(crate) fn find_loaded_subagent_threads_for_primary( loaded_threads } -fn thread_spawn_parent_thread_id( - source: &codex_app_server_protocol::SessionSource, -) -> Option { - let value = serde_json::to_value(source).ok()?; - let parent_thread_id = value - .get("subAgent")? - .get("thread_spawn")? - .get("parent_thread_id")? - .as_str()?; - ThreadId::from_string(parent_thread_id).ok() +fn thread_spawn_agent_path(source: &SessionSource) -> Option { + match source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => { + agent_path.clone().map(String::from) + } + _ => None, + } +} + +fn thread_spawn_parent_thread_id(source: &SessionSource) -> Option { + match source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) => Some(*parent_thread_id), + _ => None, + } } #[cfg(test)] @@ -208,11 +218,13 @@ mod tests { thread_id: child_thread_id, agent_nickname: Some("Scout".to_string()), agent_role: Some("explorer".to_string()), + agent_path: None, }, LoadedSubagentThread { thread_id: grandchild_thread_id, agent_nickname: Some("Atlas".to_string()), agent_role: Some("worker".to_string()), + agent_path: None, }, ] ); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 5e7985f108..a961658c80 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -8,6 +8,60 @@ use super::*; impl App { pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) { + self.backfill_loaded_subagent_threads(app_server).await; + // V2 subagents are identified by canonical paths observed from activity events or loaded + // thread metadata. Prefer local buffered turn state for liveness, and fall back to + // thread/read only when no local event channel exists. + let path_backed_thread_ids: Vec<_> = self + .agent_navigation + .ordered_path_backed_subagent_threads(self.primary_thread_id) + .into_iter() + .map(|(thread_id, _)| thread_id) + .collect(); + for thread_id in path_backed_thread_ids { + if let Some(channel) = self.thread_event_channels.get(&thread_id) + && channel.attachment() == ThreadEventAttachment::Live + { + let is_running = channel.store.lock().await.active_turn_id().is_some(); + self.agent_navigation.set_running(thread_id, is_running); + } else { + self.refresh_agent_picker_thread_liveness(app_server, thread_id) + .await; + } + } + let path_backed_threads = self + .agent_navigation + .ordered_path_backed_subagent_threads(self.primary_thread_id); + if !path_backed_threads.is_empty() { + let running_threads: Vec<_> = path_backed_threads + .into_iter() + .filter_map(|(thread_id, entry)| { + if !entry.is_running || entry.is_closed { + return None; + } + Some((thread_id, entry.agent_path.as_deref()?.trim().to_string())) + }) + .collect(); + let mut entries = Vec::new(); + for (thread_id, agent_path) in running_threads { + let preview = if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let store = channel.store.lock().await; + super::agent_status_feed::AgentStatusThreadPreview::from_store( + agent_path, &store, + ) + } else { + super::agent_status_feed::AgentStatusThreadPreview::empty(agent_path) + }; + entries.push(preview); + } + + self.chat_widget + .add_to_history(super::agent_status_feed::AgentStatusHistoryCell::new( + entries, + )); + return; + } + let mut thread_ids = self.agent_navigation.tracked_thread_ids(); for thread_id in self.thread_event_channels.keys().copied() { if !thread_ids.contains(&thread_id) { @@ -44,14 +98,14 @@ impl App { let items: Vec = self .agent_navigation .ordered_threads() - .iter() + .into_iter() .enumerate() .map(|(idx, (thread_id, entry))| { - if self.active_thread_id == Some(*thread_id) { + if self.active_thread_id == Some(thread_id) { initial_selected_idx = Some(idx); } - let id = *thread_id; - let is_primary = self.primary_thread_id == Some(*thread_id); + let id = thread_id; + let is_primary = self.primary_thread_id == Some(thread_id); let name = format_agent_picker_item_name( entry.agent_nickname.as_deref(), entry.agent_role.as_deref(), @@ -62,7 +116,7 @@ impl App { name: name.clone(), name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed), description: Some(uuid.clone()), - is_current: self.active_thread_id == Some(*thread_id), + is_current: self.active_thread_id == Some(thread_id), actions: vec![Box::new(move |tx| { tx.send(AppEvent::SelectAgentThread(id)); })], @@ -145,6 +199,14 @@ impl App { .await { Ok(thread) => { + let is_running = matches!( + thread.status, + codex_app_server_protocol::ThreadStatus::Active { .. } + ); + let is_closed = matches!( + thread.status, + codex_app_server_protocol::ThreadStatus::NotLoaded + ); self.upsert_agent_picker_thread( thread_id, thread.agent_nickname.or_else(|| { @@ -157,11 +219,9 @@ impl App { .as_ref() .and_then(|entry| entry.agent_role.clone()) }), - matches!( - thread.status, - codex_app_server_protocol::ThreadStatus::NotLoaded - ), + is_closed, ); + self.agent_navigation.set_running(thread_id, is_running); true } Err(err) => { @@ -186,6 +246,8 @@ impl App { is_closed, ); } + self.agent_navigation + .set_running(thread_id, /*is_running*/ false); true } } @@ -248,6 +310,9 @@ impl App { } }; let channel = self.ensure_thread_channel(thread_id); + if !live_attached { + channel.mark_replay_only(); + } let mut store = channel.store.lock().await; store.set_session(session, turns); Ok(live_attached) @@ -610,13 +675,17 @@ impl App { } for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) { + let agent_path = thread.agent_path; self.upsert_agent_picker_thread( thread.thread_id, thread.agent_nickname, thread.agent_role, /*is_closed*/ false, ); + self.agent_navigation + .set_agent_path(thread.thread_id, agent_path); } + self.sync_active_agent_label(); !had_read_error } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index c59102fa6d..4e5aa8b10c 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -25,6 +25,7 @@ pub(super) async fn make_test_app() -> App { cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0be0a1462f..99d4c35333 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -22,6 +22,7 @@ use crate::history_cell::PlainHistoryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; +use crate::multi_agents::SubAgentActivityDisplay; use assert_matches::assert_matches; use crate::app_command::AppCommand as Op; @@ -1164,6 +1165,8 @@ async fn collab_receiver_notification_caches_thread_without_app_server_read() { Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, + agent_path: None, + is_running: false, is_closed: false, }) ); @@ -1223,6 +1226,8 @@ async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, + agent_path: None, + is_running: false, is_closed: true, }) ); @@ -1256,6 +1261,88 @@ async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Res Some(&AgentPickerThreadEntry { agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), + agent_path: None, + is_running: false, + is_closed: true, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn open_agent_picker_clears_completed_path_backed_agent_running_state() -> Result<()> { + let mut app = Box::pin(make_test_app()).await; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + let channel = ThreadEventChannel::new(/*capacity*/ 4); + { + let mut store = channel.store.lock().await; + store.push_notification(turn_started_notification(thread_id, "turn-1")); + store.push_notification(turn_completed_notification( + thread_id, + "turn-1", + TurnStatus::Completed, + )); + } + app.thread_event_channels.insert(thread_id, channel); + app.agent_navigation + .record_sub_agent_activity(SubAgentActivityDisplay { + thread_id, + agent_path: "/root/child".to_string(), + is_running_hint: true, + }); + + Box::pin(app.open_agent_picker(&mut app_server)).await; + + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + agent_path: Some("/root/child".to_string()), + is_running: false, + is_closed: false, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn open_agent_picker_refreshes_replay_only_path_backed_liveness() -> Result<()> { + let mut app = Box::pin(make_test_app()).await; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + let mut channel = ThreadEventChannel::new(/*capacity*/ 4); + channel.mark_replay_only(); + { + let mut store = channel.store.lock().await; + store.push_notification(turn_started_notification(thread_id, "turn-1")); + } + app.thread_event_channels.insert(thread_id, channel); + app.agent_navigation + .record_sub_agent_activity(SubAgentActivityDisplay { + thread_id, + agent_path: "/root/child".to_string(), + is_running_hint: true, + }); + + Box::pin(app.open_agent_picker(&mut app_server)).await; + + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + agent_path: Some("/root/child".to_string()), + is_running: false, is_closed: true, }) ); @@ -1310,6 +1397,8 @@ async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { Some(&AgentPickerThreadEntry { agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), + agent_path: None, + is_running: false, is_closed: true, }) ); @@ -1348,6 +1437,8 @@ fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, + agent_path: None, + is_running: false, is_closed: false, }) ); @@ -2819,6 +2910,8 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re Some(&AgentPickerThreadEntry { agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), + agent_path: None, + is_running: false, is_closed: false, }) ); @@ -3945,6 +4038,7 @@ async fn make_test_app() -> App { cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, @@ -4009,6 +4103,7 @@ async fn make_test_app_with_channels() -> ( cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 5f7a58da1f..48dc1d0848 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -31,6 +31,12 @@ pub(super) struct FeedbackThreadEvent { pub(super) result: Result, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ThreadEventAttachment { + Live, + ReplayOnly, +} + #[derive(Debug)] pub(super) struct ThreadEventStore { pub(super) session: Option, @@ -285,6 +291,7 @@ pub(super) struct ThreadEventChannel { pub(super) sender: mpsc::Sender, pub(super) receiver: Option>, pub(super) store: Arc>, + attachment: ThreadEventAttachment, } impl ThreadEventChannel { @@ -294,9 +301,18 @@ impl ThreadEventChannel { sender, receiver: Some(receiver), store: Arc::new(Mutex::new(ThreadEventStore::new(capacity))), + attachment: ThreadEventAttachment::Live, } } + pub(super) fn mark_replay_only(&mut self) { + self.attachment = ThreadEventAttachment::ReplayOnly; + } + + pub(super) fn attachment(&self) -> ThreadEventAttachment { + self.attachment + } + #[cfg_attr(not(test), allow(dead_code))] pub(super) fn new_with_session( capacity: usize, @@ -310,6 +326,7 @@ impl ThreadEventChannel { store: Arc::new(Mutex::new(ThreadEventStore::new_with_session( capacity, session, turns, ))), + attachment: ThreadEventAttachment::Live, } } } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 099c81fb70..80b67b19d6 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -896,6 +896,14 @@ impl App { &mut self, notification: &ServerNotification, ) { + if let Some(activity) = + sub_agent_activity_item(notification).and_then(sub_agent_activity_display) + { + self.agent_navigation.record_sub_agent_activity(activity); + self.sync_active_agent_label(); + return; + } + let Some(receiver_thread_ids) = collab_receiver_thread_ids(notification) else { return; }; diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 32db3d2cf7..0ca92e0830 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -22,11 +22,6 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigWriteResponse; -use codex_app_server_protocol::ExternalAgentConfigDetectParams; -use codex_app_server_protocol::ExternalAgentConfigDetectResponse; -use codex_app_server_protocol::ExternalAgentConfigImportParams; -use codex_app_server_protocol::ExternalAgentConfigImportResponse; -use codex_app_server_protocol::ExternalAgentConfigMigrationItem; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; @@ -349,31 +344,6 @@ impl AppServerSession { .map_err(|err| bootstrap_request_error("account/read failed during TUI bootstrap", err)) } - pub(crate) async fn external_agent_config_detect( - &mut self, - params: ExternalAgentConfigDetectParams, - ) -> Result { - let request_id = self.next_request_id(); - self.client - .request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params }) - .await - .wrap_err("externalAgentConfig/detect failed during TUI startup") - } - - pub(crate) async fn external_agent_config_import( - &mut self, - migration_items: Vec, - ) -> Result { - let request_id = self.next_request_id(); - self.client - .request_typed(ClientRequest::ExternalAgentConfigImport { - request_id, - params: ExternalAgentConfigImportParams { migration_items }, - }) - .await - .wrap_err("externalAgentConfig/import failed during TUI startup") - } - pub(crate) async fn next_event(&mut self) -> Option { self.client.next_event().await } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 9b77da0372..9d29208075 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -327,6 +327,7 @@ impl ChatWidget { reasoning_effort, agents_states, }), + item @ ThreadItem::SubAgentActivity { .. } => self.on_sub_agent_activity(item), ThreadItem::EnteredReviewMode { review, .. } if !from_replay => { self.enter_review_mode_with_hint(review, /*from_replay*/ false); } diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index f0211aaca1..bdb8b8d3a1 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -190,6 +190,7 @@ impl ChatWidget { reasoning_effort, agents_states, }), + item @ ThreadItem::SubAgentActivity { .. } => self.on_sub_agent_activity(item), ThreadItem::DynamicToolCall { .. } => {} } diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index 5df5e7bc12..43d4c2e3cf 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -144,6 +144,13 @@ impl ChatWidget { } } + pub(super) fn on_sub_agent_activity(&mut self, item: ThreadItem) { + self.record_visible_turn_activity(); + if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) { + self.on_collab_event(cell); + } + } + pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { let ThreadItem::FileChange { status, .. } = item else { return; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 620b152b99..ebd2042a10 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -1,5 +1,6 @@ use crate::history_cell::PlainHistoryCell; use crate::legacy_core::config::Config; +use crate::legacy_core::config::Permissions; use crate::session_state::SessionNetworkProxyRuntime; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; @@ -15,6 +16,8 @@ use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::WebSearchModeRequirement; use codex_config::format_config_layer_source; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use ratatui::style::Stylize; use ratatui::text::Line; use toml::Value as TomlValue; @@ -23,7 +26,9 @@ pub(crate) fn new_debug_config_output( config: &Config, session_network_proxy: Option<&SessionNetworkProxyRuntime>, ) -> PlainHistoryCell { - let mut lines = render_debug_config_lines(&config.config_layer_stack); + let mut lines = render_debug_config_lines(&config.config_layer_stack, |mode| { + sandbox_mode_is_allowed_by_permissions(&config.permissions, mode) + }); if let Some(proxy) = session_network_proxy { lines.push("".into()); @@ -49,6 +54,24 @@ pub(crate) fn new_debug_config_output( PlainHistoryCell::new(lines) } +fn sandbox_mode_is_allowed_by_permissions( + permissions: &Permissions, + mode: SandboxModeRequirement, +) -> bool { + let permission_profile = match mode { + SandboxModeRequirement::ReadOnly => PermissionProfile::read_only(), + SandboxModeRequirement::WorkspaceWrite => PermissionProfile::workspace_write(), + SandboxModeRequirement::DangerFullAccess => PermissionProfile::Disabled, + SandboxModeRequirement::ExternalSandbox => PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + }; + + permissions + .can_set_permission_profile(&permission_profile) + .is_ok() +} + fn session_all_proxy_url(http_addr: &str, socks_addr: &str, socks_enabled: bool) -> String { if socks_enabled { format!("socks5h://{socks_addr}") @@ -57,7 +80,10 @@ fn session_all_proxy_url(http_addr: &str, socks_addr: &str, socks_enabled: bool) } } -fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { +fn render_debug_config_lines( + stack: &ConfigLayerStack, + sandbox_mode_is_effectively_allowed: impl Fn(SandboxModeRequirement) -> bool, +) -> Vec> { let mut lines = vec!["/debug-config".magenta().into(), "".into()]; lines.push( @@ -122,6 +148,7 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { modes .iter() .copied() + .filter(|mode| sandbox_mode_is_effectively_allowed(*mode)) .map(format_sandbox_mode_requirement) .collect::>(), ); @@ -515,8 +542,11 @@ fn format_network_unix_socket_permission( #[cfg(test)] mod tests { use super::render_debug_config_lines; + use super::sandbox_mode_is_allowed_by_permissions; use super::session_all_proxy_url; use crate::legacy_core::config::Constrained; + use crate::legacy_core::config::ConstraintError; + use crate::legacy_core::config::Permissions; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigLayerSource; use codex_config::ConfigLayerEntry; @@ -542,6 +572,7 @@ mod tests { use codex_config::SandboxModeRequirement; use codex_config::Sourced; use codex_config::WebSearchModeRequirement; + use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; @@ -571,6 +602,20 @@ mod tests { .join("\n") } + fn render_stack_to_text(stack: &ConfigLayerStack) -> String { + render_stack_to_text_with_sandbox_mode_filter(stack, |_| true) + } + + fn render_stack_to_text_with_sandbox_mode_filter( + stack: &ConfigLayerStack, + sandbox_mode_is_effectively_allowed: impl Fn(SandboxModeRequirement) -> bool, + ) -> String { + render_to_text(&render_debug_config_lines( + stack, + sandbox_mode_is_effectively_allowed, + )) + } + #[test] fn debug_config_output_lists_all_layers_including_disabled() { let system_file = if cfg!(windows) { @@ -604,7 +649,7 @@ mod tests { ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("(enabled)")); assert!(rendered.contains("(disabled)")); assert!(rendered.contains("reason: project is untrusted")); @@ -749,7 +794,7 @@ mod tests { ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "allowed_approval_policies: on-request (source: {requirements_source})" @@ -800,6 +845,93 @@ mod tests { assert!(!rendered.contains(" - rules:")); } + #[test] + fn debug_config_output_filters_sandbox_modes_blocked_by_deny_read_requirements() { + let requirements_file = if cfg!(windows) { + absolute_path("C:\\ProgramData\\OpenAI\\Codex\\requirements.toml") + } else { + absolute_path("/etc/codex/requirements.toml") + }; + let denied_path = if cfg!(windows) { + absolute_path("C:\\Users\\alice\\.gitconfig") + } else { + absolute_path("/home/alice/.gitconfig") + }; + + let requirements = ConfigRequirements { + permission_profile: ConstrainedWithSource::new( + Constrained::allow_any(PermissionProfile::read_only()), + Some(RequirementSource::SystemRequirementsToml { + file: requirements_file.clone(), + }), + ), + filesystem: Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![denied_path.into()], + }, + RequirementSource::SystemRequirementsToml { + file: requirements_file.clone(), + }, + )), + ..ConfigRequirements::default() + }; + let requirements_toml = ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + SandboxModeRequirement::DangerFullAccess, + SandboxModeRequirement::ExternalSandbox, + ]), + ..ConfigRequirementsToml::default() + }; + let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("config layer stack"); + let constrained_permission_profile = + Constrained::new(PermissionProfile::read_only(), |candidate| { + let mode = sandbox_mode_requirement_for_permission_profile(candidate); + match mode { + SandboxModeRequirement::ReadOnly | SandboxModeRequirement::WorkspaceWrite => { + Ok(()) + } + SandboxModeRequirement::DangerFullAccess + | SandboxModeRequirement::ExternalSandbox => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + } + }) + .expect("constrained permission profile"); + let permissions = Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::OnRequest.to_core()), + constrained_permission_profile, + ) + .expect("permissions"); + + let rendered = render_stack_to_text_with_sandbox_mode_filter(&stack, |mode| { + sandbox_mode_is_allowed_by_permissions(&permissions, mode) + }); + #[cfg(not(windows))] + insta::assert_snapshot!( + "debug_config_effective_sandbox_modes_with_deny_read", + rendered.as_str() + ); + assert!( + rendered.contains( + format!( + "allowed_sandbox_modes: read-only, workspace-write (source: {})", + requirements_file.as_path().display() + ) + .as_str() + ) + ); + assert!(!rendered.contains("danger-full-access")); + assert!(!rendered.contains("external-sandbox")); + } + #[test] fn debug_config_output_lists_approvals_reviewer_as_requirement() { let requirements = ConfigRequirements { @@ -816,7 +948,7 @@ mod tests { let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains( "allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))" )); @@ -851,7 +983,7 @@ mod tests { ConfigLayerStack::new(Vec::new(), requirements, ConfigRequirementsToml::default()) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})" @@ -880,7 +1012,7 @@ writable_roots = ["/tmp"] ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("session-flags (enabled)")); assert!(rendered.contains(" - model = \"gpt-5\"")); assert!(rendered.contains(" - sandbox_workspace_write.network_access = true")); @@ -914,7 +1046,7 @@ approval_policy = "never" ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("legacy managed_config.toml (MDM) (enabled)")); assert!(rendered.contains("MDM value:")); assert!(rendered.contains("# managed by MDM")); @@ -951,7 +1083,7 @@ approval_policy = "never" ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("enterprise-managed (Base policy, cfg_123) (enabled)")); assert!(rendered.contains("Enterprise-managed config value:")); assert!(!rendered.contains("MDM value:")); @@ -997,7 +1129,7 @@ approval_policy = "never" let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "allowed_web_search_modes: disabled (source: {requirements_source})" @@ -1043,7 +1175,7 @@ approval_policy = "never" let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains("hooks:")); assert!(rendered.contains("handlers=1")); diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs deleted file mode 100644 index 1ce9d44751..0000000000 --- a/codex-rs/tui/src/external_agent_config_migration.rs +++ /dev/null @@ -1,1052 +0,0 @@ -use crate::diff_render::display_path_for; -use crate::key_hint; -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; -use crate::render::Insets; -use crate::render::RectExt as _; -use crate::selection_list::selection_option_row_with_dim; -use crate::style::accent_style; -use crate::tui::FrameRequester; -use crate::tui::Tui; -use crate::tui::TuiEvent; -use codex_app_server_protocol::ExternalAgentConfigMigrationItem; -use codex_app_server_protocol::PluginsMigration; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::prelude::Stylize as _; -use ratatui::text::Line; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use tokio_stream::StreamExt; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum ExternalAgentConfigMigrationOutcome { - Proceed(Vec), - Skip, - SkipForever, - Exit, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FocusArea { - Items, - Actions, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ActionMenuOption { - Proceed, - Skip, - SkipForever, -} - -impl ActionMenuOption { - fn label(self) -> &'static str { - match self { - Self::Proceed => "Proceed with selected", - Self::Skip => "Skip for now", - Self::SkipForever => "Don't ask again", - } - } - - fn previous(self) -> Option { - match self { - Self::Proceed => None, - Self::Skip => Some(Self::Proceed), - Self::SkipForever => Some(Self::Skip), - } - } - - fn next(self) -> Option { - match self { - Self::Proceed => Some(Self::Skip), - Self::Skip => Some(Self::SkipForever), - Self::SkipForever => None, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct MigrationSelection { - item: ExternalAgentConfigMigrationItem, - enabled: bool, -} - -struct RenderLineEntry { - item_idx: Option, - kind: RenderLineKind, - line: Line<'static>, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RenderLineKind { - Section, - Item, - ItemDetail, -} - -pub(crate) async fn run_external_agent_config_migration_prompt( - tui: &mut Tui, - items: &[ExternalAgentConfigMigrationItem], - selected_items: &[ExternalAgentConfigMigrationItem], - error: Option<&str>, -) -> ExternalAgentConfigMigrationOutcome { - let mut screen = ExternalAgentConfigMigrationScreen::new( - tui.frame_requester(), - items, - selected_items, - error.map(str::to_owned), - ); - - let _ = tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&screen, frame.area()); - }); - - let events = tui.event_stream(); - tokio::pin!(events); - - while !screen.is_done() { - if let Some(event) = events.next().await { - match event { - TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} - TuiEvent::Draw | TuiEvent::Resize => { - let _ = tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&screen, frame.area()); - }); - } - } - } else { - screen.skip(); - break; - } - } - - screen.outcome() -} - -struct ExternalAgentConfigMigrationScreen { - request_frame: FrameRequester, - items: Vec, - selected_item_idx: Option, - scroll_top: usize, - focus: FocusArea, - highlighted_action: ActionMenuOption, - done: bool, - outcome: ExternalAgentConfigMigrationOutcome, - error: Option, -} - -impl ExternalAgentConfigMigrationScreen { - fn proceed_enabled(&self) -> bool { - self.selected_count() > 0 - } - - fn first_available_action(&self) -> ActionMenuOption { - if self.proceed_enabled() { - ActionMenuOption::Proceed - } else { - ActionMenuOption::Skip - } - } - - fn previous_available_action(&self, action: ActionMenuOption) -> Option { - let mut candidate = action.previous(); - while let Some(option) = candidate { - if option != ActionMenuOption::Proceed || self.proceed_enabled() { - return Some(option); - } - candidate = option.previous(); - } - None - } - - fn next_available_action(&self, action: ActionMenuOption) -> Option { - let mut candidate = action.next(); - while let Some(option) = candidate { - if option != ActionMenuOption::Proceed || self.proceed_enabled() { - return Some(option); - } - candidate = option.next(); - } - None - } - - fn normalize_highlighted_action(&mut self) { - if self.highlighted_action == ActionMenuOption::Proceed && !self.proceed_enabled() { - self.highlighted_action = self.first_available_action(); - } - } - - fn display_description(item: &ExternalAgentConfigMigrationItem) -> String { - let Some(cwd) = item.cwd.as_deref() else { - return item.description.clone(); - }; - - fn reformat_description( - description: &str, - prefix: &str, - separator: &str, - cwd: &std::path::Path, - ) -> Option { - let remainder = description.strip_prefix(prefix)?; - let (left, right) = remainder.split_once(separator)?; - Some(format!( - "{prefix}{}{}{}", - display_path_for(std::path::Path::new(left), cwd), - separator, - display_path_for(std::path::Path::new(right), cwd) - )) - } - - if let Some(reformatted) = - reformat_description(&item.description, "Migrate ", " into ", cwd) - { - return reformatted; - } - - if let Some(reformatted) = - reformat_description(&item.description, "Migrate skills from ", " to ", cwd) - { - return reformatted; - } - - if let Some(reformatted) = reformat_description(&item.description, "Migrate ", " to ", cwd) - { - return reformatted; - } - - if let Some(reformatted) = reformat_description(&item.description, "Import ", " to ", cwd) { - return reformatted; - } - - if let Some(source) = item - .description - .strip_prefix("Migrate enabled plugins from ") - { - let description = format!( - "Migrate enabled plugins from {}", - display_path_for(std::path::Path::new(source), cwd) - ); - if let Some(details) = &item.details { - let marketplace_count = details.plugins.len(); - let plugin_count = details - .plugins - .iter() - .map(|plugin_group| plugin_group.plugin_names.len()) - .sum::(); - return format!( - "{description} ({marketplace_count} {}, {plugin_count} {})", - if marketplace_count == 1 { - "marketplace" - } else { - "marketplaces" - }, - if plugin_count == 1 { - "plugin" - } else { - "plugins" - } - ); - } - return description; - } - - item.description.clone() - } - - fn new( - request_frame: FrameRequester, - items: &[ExternalAgentConfigMigrationItem], - selected_items: &[ExternalAgentConfigMigrationItem], - error: Option, - ) -> Self { - let items = items - .iter() - .cloned() - .map(|item| MigrationSelection { - enabled: selected_items.contains(&item), - item, - }) - .collect::>(); - let selected_item_idx = (!items.is_empty()).then_some(0); - Self { - request_frame, - items, - selected_item_idx, - scroll_top: 0, - focus: FocusArea::Items, - highlighted_action: ActionMenuOption::Proceed, - done: false, - outcome: ExternalAgentConfigMigrationOutcome::Skip, - error, - } - } - - fn plugin_detail_lines(plugin_groups: &[PluginsMigration]) -> Vec> { - let mut lines = plugin_groups - .iter() - .take(3) - .map(|plugin_group| { - let mut plugin_names = plugin_group - .plugin_names - .iter() - .take(2) - .cloned() - .collect::>(); - let hidden_plugin_count = plugin_group - .plugin_names - .len() - .saturating_sub(plugin_names.len()); - if hidden_plugin_count > 0 { - plugin_names.push(format!("+{hidden_plugin_count} more")); - } - Line::from(format!( - " • {}: {}", - plugin_group.marketplace_name, - plugin_names.join(", ") - )) - }) - .collect::>(); - let hidden_marketplace_count = plugin_groups.len().saturating_sub(lines.len()); - if hidden_marketplace_count > 0 { - lines.push(Line::from(format!( - " • +{hidden_marketplace_count} more marketplaces" - ))); - } - lines - } - - fn is_done(&self) -> bool { - self.done - } - - fn outcome(&self) -> ExternalAgentConfigMigrationOutcome { - self.outcome.clone() - } - - fn finish_with(&mut self, outcome: ExternalAgentConfigMigrationOutcome) { - self.outcome = outcome; - self.done = true; - self.request_frame.schedule_frame(); - } - - fn proceed(&mut self) { - let selected = self.selected_items(); - if selected.is_empty() { - self.error = Some("Select at least one item or choose a skip option.".to_string()); - self.request_frame.schedule_frame(); - return; - } - - self.finish_with(ExternalAgentConfigMigrationOutcome::Proceed(selected)); - } - - fn skip(&mut self) { - self.finish_with(ExternalAgentConfigMigrationOutcome::Skip); - } - - fn skip_forever(&mut self) { - self.finish_with(ExternalAgentConfigMigrationOutcome::SkipForever); - } - - fn exit(&mut self) { - self.finish_with(ExternalAgentConfigMigrationOutcome::Exit); - } - - fn selected_items(&self) -> Vec { - self.items - .iter() - .filter(|item| item.enabled) - .map(|item| item.item.clone()) - .collect() - } - - fn selected_count(&self) -> usize { - self.items.iter().filter(|item| item.enabled).count() - } - - fn set_all_enabled(&mut self, enabled: bool) { - for item in &mut self.items { - item.enabled = enabled; - } - self.error = None; - self.normalize_highlighted_action(); - self.request_frame.schedule_frame(); - } - - fn toggle_selected_item(&mut self) { - if self.focus != FocusArea::Items { - return; - } - let Some(selected_idx) = self.selected_item_idx else { - return; - }; - let Some(item) = self.items.get_mut(selected_idx) else { - return; - }; - - item.enabled = !item.enabled; - self.error = None; - self.normalize_highlighted_action(); - self.request_frame.schedule_frame(); - } - - fn move_up(&mut self) { - match self.focus { - FocusArea::Items => match self.selected_item_idx { - Some(0) => { - self.focus = FocusArea::Actions; - self.highlighted_action = ActionMenuOption::SkipForever; - } - Some(idx) => { - self.selected_item_idx = Some(idx.saturating_sub(1)); - } - None => { - self.focus = FocusArea::Actions; - self.highlighted_action = ActionMenuOption::SkipForever; - } - }, - FocusArea::Actions => { - if let Some(previous) = self.previous_available_action(self.highlighted_action) { - self.highlighted_action = previous; - } else { - self.focus = FocusArea::Items; - if !self.items.is_empty() { - self.selected_item_idx = Some(self.items.len() - 1); - } - } - } - } - self.ensure_selected_item_visible(); - self.request_frame.schedule_frame(); - } - - fn move_down(&mut self) { - match self.focus { - FocusArea::Items => match self.selected_item_idx { - Some(idx) if idx + 1 < self.items.len() => { - self.selected_item_idx = Some(idx + 1); - } - _ => { - self.focus = FocusArea::Actions; - self.highlighted_action = self.first_available_action(); - } - }, - FocusArea::Actions => { - if let Some(next) = self.next_available_action(self.highlighted_action) { - self.highlighted_action = next; - } else { - self.focus = FocusArea::Items; - if !self.items.is_empty() { - self.selected_item_idx = Some(0); - } - } - } - } - self.ensure_selected_item_visible(); - self.request_frame.schedule_frame(); - } - - fn confirm_selection(&mut self) { - match self.focus { - FocusArea::Items => self.toggle_selected_item(), - FocusArea::Actions => match self.highlighted_action { - ActionMenuOption::Proceed => self.proceed(), - ActionMenuOption::Skip => self.skip(), - ActionMenuOption::SkipForever => self.skip_forever(), - }, - } - } - - fn handle_key(&mut self, key_event: KeyEvent) { - if key_event.kind == KeyEventKind::Release { - return; - } - - if is_ctrl_exit_combo(key_event) { - self.exit(); - return; - } - - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => self.move_up(), - KeyCode::Down | KeyCode::Char('j') => self.move_down(), - KeyCode::Char('1') => { - self.focus = FocusArea::Actions; - self.highlighted_action = ActionMenuOption::Proceed; - self.proceed(); - } - KeyCode::Char('2') => { - self.focus = FocusArea::Actions; - self.highlighted_action = ActionMenuOption::Skip; - self.skip(); - } - KeyCode::Char('3') => { - self.focus = FocusArea::Actions; - self.highlighted_action = ActionMenuOption::SkipForever; - self.skip_forever(); - } - KeyCode::Char(' ') => self.toggle_selected_item(), - KeyCode::Char('a') => self.set_all_enabled(/*enabled*/ true), - KeyCode::Char('n') => self.set_all_enabled(/*enabled*/ false), - KeyCode::Enter => self.confirm_selection(), - KeyCode::Esc => self.skip(), - _ => {} - } - } - - fn ensure_selected_item_visible(&mut self) { - let Some(selected_idx) = self.selected_item_idx else { - self.scroll_top = 0; - return; - }; - let selected_render_idx = self.selected_render_line_index(selected_idx); - let visible_rows = self.render_line_count().max(1); - if selected_render_idx < self.scroll_top { - self.scroll_top = selected_render_idx; - } else { - let bottom = self.scroll_top + visible_rows.saturating_sub(1); - if selected_render_idx > bottom { - self.scroll_top = selected_render_idx + 1 - visible_rows; - } - } - } - - fn render_line_count(&self) -> usize { - self.build_render_lines().len() - } - - fn selected_render_line_index(&self, selected_item_idx: usize) -> usize { - self.build_render_lines() - .iter() - .position(|entry| entry.item_idx == Some(selected_item_idx)) - .unwrap_or(selected_item_idx) - } - - fn section_title(cwd: Option<&std::path::Path>) -> Line<'static> { - match cwd { - Some(cwd) => Line::from(vec!["Project: ".bold(), cwd.display().to_string().dim()]), - None => Line::from("Home".bold()), - } - } - - fn build_render_lines(&self) -> Vec { - let mut lines = Vec::new(); - let mut current_scope: Option> = None; - for (idx, item) in self.items.iter().enumerate() { - let scope = item.item.cwd.as_deref(); - if current_scope != Some(scope) { - if current_scope.is_some() { - lines.push(RenderLineEntry { - item_idx: None, - kind: RenderLineKind::Section, - line: Line::from(""), - }); - } - lines.push(RenderLineEntry { - item_idx: None, - kind: RenderLineKind::Section, - line: Self::section_title(scope), - }); - current_scope = Some(scope); - } - lines.push(RenderLineEntry { - item_idx: Some(idx), - kind: RenderLineKind::Item, - line: Line::from(format!( - " [{}] {}", - if item.enabled { "x" } else { " " }, - Self::display_description(&item.item) - )), - }); - if let Some(details) = &item.item.details { - for line in Self::plugin_detail_lines(&details.plugins) { - lines.push(RenderLineEntry { - item_idx: None, - kind: RenderLineKind::ItemDetail, - line, - }); - } - } - } - lines - } - - fn render_items(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - let rows = self.build_render_lines(); - let visible_rows = area.height as usize; - let mut start_idx = self.scroll_top.min(rows.len().saturating_sub(1)); - if let Some(selected_item_idx) = self.selected_item_idx { - let selected_render_idx = self.selected_render_line_index(selected_item_idx); - if selected_render_idx < start_idx { - start_idx = selected_render_idx; - } else if visible_rows > 0 { - let bottom = start_idx + visible_rows - 1; - if selected_render_idx > bottom { - start_idx = selected_render_idx + 1 - visible_rows; - } - } - } - - let mut y = area.y; - for entry in rows.iter().skip(start_idx).take(visible_rows) { - if y >= area.y + area.height { - break; - } - - let selected = - self.focus == FocusArea::Items && self.selected_item_idx == entry.item_idx; - let mut line = entry.line.clone(); - if selected { - line.spans.iter_mut().for_each(|span| { - span.style = span.style.patch(accent_style()); - }); - } else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() { - line.spans.iter_mut().for_each(|span| { - span.style = span.style.dim(); - }); - } - let line = truncate_line_with_ellipsis_if_overflow(line, area.width as usize); - line.render( - Rect { - x: area.x, - y, - width: area.width, - height: 1, - }, - buf, - ); - y = y.saturating_add(1); - } - } -} - -impl WidgetRef for &ExternalAgentConfigMigrationScreen { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - Clear.render(area, buf); - - let inner_area = area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)); - let error_height = u16::from(self.error.is_some()); - let fixed_height = 1u16 + 2u16 + error_height + 1u16 + 4u16 + 1u16; - let list_height = - self.render_line_count() - .max(1) - .min(inner_area.height.saturating_sub(fixed_height) as usize) as u16; - let [ - header_area, - intro_area, - error_area, - list_area, - list_gap_area, - actions_area, - footer_area, - _spacer_area, - ] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(2), - Constraint::Length(error_height), - Constraint::Length(list_height), - Constraint::Length(1), - Constraint::Length(4), - Constraint::Length(1), - Constraint::Fill(1), - ]) - .areas(inner_area); - - let heading = Line::from(vec!["> ".into(), "External agent config detected".bold()]); - heading.render(header_area, buf); - - Paragraph::new(vec![ - Line::from("We found settings from another agent that you can add to this project."), - Line::from("Select what to import"), - ]) - .wrap(Wrap { trim: false }) - .render(intro_area, buf); - - if let Some(error) = &self.error { - Paragraph::new(error.clone().red().to_string()) - .wrap(Wrap { trim: false }) - .render(error_area, buf); - } - - self.render_items(list_area, buf); - Clear.render(list_gap_area, buf); - - let [ - actions_intro_area, - proceed_area, - skip_area, - skip_forever_area, - ] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(actions_area); - let actions_intro = format!( - "Selected {} of {} item(s).", - self.selected_count(), - self.items.len() - ); - Paragraph::new(actions_intro) - .wrap(Wrap { trim: false }) - .render(actions_intro_area, buf); - selection_option_row_with_dim( - /*index*/ 0, - ActionMenuOption::Proceed.label().to_string(), - self.focus == FocusArea::Actions - && self.highlighted_action == ActionMenuOption::Proceed, - /*dim*/ self.focus != FocusArea::Actions || !self.proceed_enabled(), - ) - .render(proceed_area, buf); - selection_option_row_with_dim( - /*index*/ 1, - ActionMenuOption::Skip.label().to_string(), - self.focus == FocusArea::Actions && self.highlighted_action == ActionMenuOption::Skip, - /*dim*/ self.focus != FocusArea::Actions, - ) - .render(skip_area, buf); - selection_option_row_with_dim( - /*index*/ 2, - ActionMenuOption::SkipForever.label().to_string(), - self.focus == FocusArea::Actions - && self.highlighted_action == ActionMenuOption::SkipForever, - /*dim*/ self.focus != FocusArea::Actions, - ) - .render(skip_forever_area, buf); - - Line::from(vec![ - "Use ".dim(), - key_hint::plain(KeyCode::Up).into(), - "/".dim(), - key_hint::plain(KeyCode::Down).into(), - " to move, ".dim(), - key_hint::plain(KeyCode::Char(' ')).into(), - " to toggle, ".dim(), - "1".cyan(), - "/".dim(), - "2".cyan(), - "/".dim(), - "3".cyan(), - " to choose, ".dim(), - "a".cyan(), - "/".dim(), - "n".cyan(), - " for all/none".dim(), - ]) - .render(footer_area, buf); - } -} - -fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool { - key_event.modifiers.contains(KeyModifiers::CONTROL) - && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) -} - -#[cfg(test)] -mod tests { - use super::ActionMenuOption; - use super::ExternalAgentConfigMigrationOutcome; - use super::ExternalAgentConfigMigrationScreen; - use super::FocusArea; - use crate::custom_terminal::Terminal; - use crate::test_backend::VT100Backend; - use crate::tui::FrameRequester; - use codex_app_server_protocol::ExternalAgentConfigMigrationItem; - use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; - use codex_app_server_protocol::PluginsMigration; - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - use insta::assert_snapshot; - use pretty_assertions::assert_eq; - use ratatui::layout::Rect; - use std::path::PathBuf; - - fn sample_plugin_details() -> codex_app_server_protocol::MigrationDetails { - codex_app_server_protocol::MigrationDetails { - plugins: vec![ - PluginsMigration { - marketplace_name: "acme-tools".to_string(), - plugin_names: vec![ - "deployer".to_string(), - "formatter".to_string(), - "lint".to_string(), - ], - }, - PluginsMigration { - marketplace_name: "team-marketplace".to_string(), - plugin_names: vec!["asana".to_string()], - }, - PluginsMigration { - marketplace_name: "debug".to_string(), - plugin_names: vec!["sample".to_string()], - }, - PluginsMigration { - marketplace_name: "data-tools".to_string(), - plugin_names: vec!["warehouse".to_string()], - }, - ], - ..Default::default() - } - } - - #[cfg(windows)] - fn sample_project_root() -> PathBuf { - PathBuf::from(r"C:\workspace\project") - } - - #[cfg(not(windows))] - fn sample_project_root() -> PathBuf { - PathBuf::from("/workspace/project") - } - - fn sample_project_path(path: &str) -> String { - sample_project_root().join(path).display().to_string() - } - - fn sample_items() -> Vec { - let project_root = sample_project_root(); - vec![ - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Config, - description: - "Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/config.toml" - .to_string(), - cwd: None, - details: None, - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: format!( - "Migrate enabled plugins from {}", - sample_project_path(".claude/settings.json") - ), - cwd: Some(project_root.clone()), - details: Some(sample_plugin_details()), - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: format!( - "Migrate {} to {}", - sample_project_path("CLAUDE.md"), - sample_project_path("AGENTS.md") - ), - cwd: Some(project_root), - details: None, - }, - ] - } - - fn render_screen( - screen: &ExternalAgentConfigMigrationScreen, - width: u16, - height: u16, - ) -> String { - let backend = VT100Backend::new(width, height); - let mut terminal = Terminal::with_options(backend).expect("terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - { - let mut frame = terminal.get_frame(); - frame.render_widget_ref(screen, frame.area()); - } - terminal.flush().expect("flush"); - terminal.backend().to_string() - } - - #[test] - fn prompt_snapshot() { - let items = sample_items(); - let screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - - let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 21); - #[cfg(windows)] - assert_snapshot!("external_agent_config_migration_prompt_windows", rendered); - #[cfg(not(windows))] - assert_snapshot!("external_agent_config_migration_prompt", rendered); - } - - #[test] - fn proceed_returns_selected_items() { - let items = sample_items(); - let mut screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(screen.is_done()); - assert_eq!( - screen.outcome(), - ExternalAgentConfigMigrationOutcome::Proceed(items) - ); - } - - #[test] - fn toggle_item_then_proceed_keeps_remaining_selection() { - let items = sample_items(); - let mut screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - - screen.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(screen.is_done()); - assert_eq!( - screen.outcome(), - ExternalAgentConfigMigrationOutcome::Proceed(vec![items[1].clone(), items[2].clone(),]) - ); - } - - #[test] - fn escape_skips_prompt() { - let items = sample_items(); - let mut screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - - screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(screen.is_done()); - assert_eq!(screen.outcome(), ExternalAgentConfigMigrationOutcome::Skip); - } - - #[test] - fn skip_forever_returns_skip_forever_outcome() { - let items = sample_items(); - let mut screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - - screen.move_down(); - screen.move_down(); - screen.move_down(); - screen.move_down(); - screen.move_down(); - screen.confirm_selection(); - - assert_eq!( - screen.outcome(), - ExternalAgentConfigMigrationOutcome::SkipForever - ); - } - - #[test] - fn proceed_requires_at_least_one_selected_item() { - let items = sample_items(); - let mut screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - - screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - - assert!(!screen.is_done()); - assert_eq!(screen.highlighted_action, ActionMenuOption::Proceed); - let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 20); - assert!( - rendered.contains("Select at least one item or choose a skip option."), - "expected inline validation error, got:\n{rendered}" - ); - } - - #[test] - fn proceed_action_is_skipped_when_no_items_are_selected() { - let items = sample_items(); - let mut screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - - screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!(screen.focus, FocusArea::Actions); - assert_eq!(screen.highlighted_action, ActionMenuOption::Skip); - } - - #[test] - fn numeric_shortcuts_choose_actions() { - let items = sample_items(); - - let mut proceed_screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - proceed_screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - assert_eq!( - proceed_screen.outcome(), - ExternalAgentConfigMigrationOutcome::Proceed(items.clone()) - ); - - let mut skip_screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - skip_screen.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); - assert_eq!( - skip_screen.outcome(), - ExternalAgentConfigMigrationOutcome::Skip - ); - - let mut skip_forever_screen = ExternalAgentConfigMigrationScreen::new( - FrameRequester::test_dummy(), - &items, - &items, - /*error*/ None, - ); - skip_forever_screen.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); - assert_eq!( - skip_forever_screen.outcome(), - ExternalAgentConfigMigrationOutcome::SkipForever - ); - } -} diff --git a/codex-rs/tui/src/get_git_diff.rs b/codex-rs/tui/src/get_git_diff.rs index 7f55450707..ab7764b6b3 100644 --- a/codex-rs/tui/src/get_git_diff.rs +++ b/codex-rs/tui/src/get_git_diff.rs @@ -11,9 +11,11 @@ use std::time::Duration; use crate::workspace_command::WorkspaceCommand; use crate::workspace_command::WorkspaceCommandExecutor; use crate::workspace_command::WorkspaceCommandOutput; +use codex_git_utils::FsmonitorOverride; +use codex_git_utils::FsmonitorProbeRunner; +use codex_git_utils::detect_fsmonitor_override; const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); -const DISABLE_FSMONITOR_CONFIG: &str = "core.fsmonitor=false"; const DISABLE_HOOKS_CONFIG: &str = if cfg!(windows) { "core.hooksPath=NUL" } else { @@ -21,6 +23,25 @@ const DISABLE_HOOKS_CONFIG: &str = if cfg!(windows) { }; const EXECUTABLE_FILTER_CONFIG_PATTERN: &str = r"^filter\..*\.(clean|process)$"; +// `/diff` may execute Git through a remote workspace, so git-utils owns the +// probe policy while this adapter keeps command execution in the TUI layer. +// WorkspaceCommand bounds each call; `/diff` has no aggregate command deadline. +struct WorkspaceFsmonitorProbeRunner<'a> { + runner: &'a dyn WorkspaceCommandExecutor, + cwd: &'a Path, +} + +impl FsmonitorProbeRunner for WorkspaceFsmonitorProbeRunner<'_> { + async fn run_probe(&mut self, args: &[&str]) -> Option> { + let argv = ["git"].into_iter().chain(args.iter().copied()); + let command = WorkspaceCommand::new(argv).cwd(self.cwd.to_path_buf()); + match self.runner.run(command).await { + Ok(output) if output.success() => Some(output.stdout.into_bytes()), + _ => None, + } + } +} + /// Return value of [`get_git_diff`]. /// /// * `bool` – Whether the current working directory is inside a Git repo. @@ -34,12 +55,17 @@ pub(crate) async fn get_git_diff( return Ok((false, String::new())); } + // Probe once per `/diff` and reuse the result for all subsequent Git commands. + let mut probe_runner = WorkspaceFsmonitorProbeRunner { runner, cwd }; + let fsmonitor = detect_fsmonitor_override(&mut probe_runner).await; + // Keep `/diff` informational: repository configuration must not select executable diff helpers. - let diff_config_overrides = diff_filter_config_overrides(runner, cwd).await?; + let diff_config_overrides = diff_filter_config_overrides(runner, cwd, fsmonitor).await?; let (tracked_diff_res, untracked_output_res) = tokio::join!( run_git_capture_diff( runner, cwd, + fsmonitor, &diff_config_overrides, &[ "diff", @@ -50,7 +76,12 @@ pub(crate) async fn get_git_diff( "--color", ] ), - run_git_capture_stdout(runner, cwd, &["ls-files", "--others", "--exclude-standard"]), + run_git_capture_stdout( + runner, + cwd, + fsmonitor, + &["ls-files", "--others", "--exclude-standard"] + ), ); let tracked_diff = tracked_diff_res?; let untracked_output = untracked_output_res?; @@ -80,7 +111,8 @@ pub(crate) async fn get_git_diff( null_path, file, ]; - let diff = run_git_capture_diff(runner, cwd, &diff_config_overrides, &args).await?; + let diff = + run_git_capture_diff(runner, cwd, fsmonitor, &diff_config_overrides, &args).await?; untracked_diff.push_str(&diff); } @@ -92,9 +124,10 @@ pub(crate) async fn get_git_diff( async fn run_git_capture_stdout( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + fsmonitor: FsmonitorOverride, args: &[&str], ) -> Result { - let output = run_git_command(runner, cwd, &[], args).await?; + let output = run_git_command(runner, cwd, fsmonitor, &[], args).await?; if output.success() { Ok(output.stdout) } else { @@ -110,10 +143,11 @@ async fn run_git_capture_stdout( async fn run_git_capture_diff( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + fsmonitor: FsmonitorOverride, config_overrides: &[(String, String)], args: &[&str], ) -> Result { - let output = run_git_command(runner, cwd, config_overrides, args).await?; + let output = run_git_command(runner, cwd, fsmonitor, config_overrides, args).await?; if output.success() || output.exit_code == 1 { Ok(output.stdout) } else { @@ -129,6 +163,7 @@ async fn run_git_capture_diff( async fn diff_filter_config_overrides( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + fsmonitor: FsmonitorOverride, ) -> Result, String> { let args = [ "config", @@ -137,7 +172,7 @@ async fn diff_filter_config_overrides( "--get-regexp", EXECUTABLE_FILTER_CONFIG_PATTERN, ]; - let output = run_git_command(runner, cwd, &[], &args).await?; + let output = run_git_command(runner, cwd, fsmonitor, &[], &args).await?; if output.exit_code != 0 && output.exit_code != 1 { return Err(format!( "git {:?} failed with status {}", @@ -174,25 +209,35 @@ async fn inside_git_repo( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, ) -> Result { - let output = run_git_command(runner, cwd, &[], &["rev-parse", "--is-inside-work-tree"]).await?; + // `rev-parse` does not inspect the worktree, and probing before this check + // would also run extra Git commands outside repositories. + let output = run_git_command( + runner, + cwd, + FsmonitorOverride::Disabled, + &[], + &["rev-parse", "--is-inside-work-tree"], + ) + .await?; Ok(output.success()) } async fn run_git_command( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + fsmonitor: FsmonitorOverride, config_overrides: &[(String, String)], args: &[&str], ) -> Result { - let mut argv = Vec::with_capacity(args.len() + 5); - argv.push("git".to_string()); - argv.extend([ - "-c".to_string(), - DISABLE_FSMONITOR_CONFIG.to_string(), - "-c".to_string(), - DISABLE_HOOKS_CONFIG.to_string(), - ]); - argv.extend(args.iter().map(|arg| (*arg).to_string())); + let argv = [ + "git", + "-c", + fsmonitor.git_config_arg(), + "-c", + DISABLE_HOOKS_CONFIG, + ] + .into_iter() + .chain(args.iter().copied()); let mut command = WorkspaceCommand::new(argv) .cwd(cwd.to_path_buf()) .timeout(DIFF_COMMAND_TIMEOUT) @@ -230,7 +275,10 @@ mod tests { async fn get_git_diff_returns_not_git_for_non_git_cwd() { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![response( - git_command(&["rev-parse", "--is-inside-work-tree"]), + git_command( + FsmonitorOverride::Disabled, + &["rev-parse", "--is-inside-work-tree"], + ), /*exit_code*/ 128, "", )]); @@ -238,11 +286,7 @@ mod tests { let result = get_git_diff(&runner, &cwd).await; assert_eq!(result, Ok((false, String::new()))); - assert_commands( - &runner.commands(), - &[git_command(&["rev-parse", "--is-inside-work-tree"])], - &cwd, - ); + assert_command_metadata(&runner.commands(), &cwd); } #[tokio::test] @@ -250,51 +294,84 @@ mod tests { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - git_command(&["rev-parse", "--is-inside-work-tree"]), + git_command( + FsmonitorOverride::Disabled, + &["rev-parse", "--is-inside-work-tree"], + ), /*exit_code*/ 0, "true\n", ), response( - git_command(&[ + git_probe_command(&["config", "--null", "--get", "core.fsmonitor"]), + /*exit_code*/ 0, + "/tmp/fsmonitor-helper\0", + ), + response( + git_probe_command(&[ "config", "--null", - "--name-only", - "--get-regexp", - EXECUTABLE_FILTER_CONFIG_PATTERN, + "--type=bool", + "--fixed-value", + "--get", + "core.fsmonitor", + "/tmp/fsmonitor-helper", ]), + /*exit_code*/ 128, + "", + ), + response( + git_command( + FsmonitorOverride::Disabled, + &[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ], + ), /*exit_code*/ 0, "filter.evil.clean\0filter.evil.process\0", ), response( - git_command(&[ - "diff", - "--no-textconv", - "--no-ext-diff", - "--submodule=short", - "--ignore-submodules=dirty", - "--color", - ]), + git_command( + FsmonitorOverride::Disabled, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ], + ), /*exit_code*/ 1, "tracked\n", ), response( - git_command(&["ls-files", "--others", "--exclude-standard"]), + git_command( + FsmonitorOverride::Disabled, + &["ls-files", "--others", "--exclude-standard"], + ), /*exit_code*/ 0, "new.txt\n", ), response( - git_command(&[ - "diff", - "--no-textconv", - "--no-ext-diff", - "--submodule=short", - "--ignore-submodules=dirty", - "--color", - "--no-index", - "--", - null_device(), - "new.txt", - ]), + git_command( + FsmonitorOverride::Disabled, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + "--no-index", + "--", + null_device(), + "new.txt", + ], + ), /*exit_code*/ 1, "untracked\n", ), @@ -304,43 +381,95 @@ mod tests { assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string()))); let commands = runner.commands(); - assert_commands( - &commands, - &[ - git_command(&["rev-parse", "--is-inside-work-tree"]), - git_command(&[ - "config", - "--null", - "--name-only", - "--get-regexp", - EXECUTABLE_FILTER_CONFIG_PATTERN, - ]), - git_command(&[ - "diff", - "--no-textconv", - "--no-ext-diff", - "--submodule=short", - "--ignore-submodules=dirty", - "--color", - ]), - git_command(&["ls-files", "--others", "--exclude-standard"]), - git_command(&[ - "diff", - "--no-textconv", - "--no-ext-diff", - "--submodule=short", - "--ignore-submodules=dirty", - "--color", - "--no-index", - "--", - null_device(), - "new.txt", - ]), - ], - &cwd, - ); - assert_eq!(commands[2].env, filter_override_env("filter.evil")); + assert_command_metadata(&commands, &cwd); assert_eq!(commands[4].env, filter_override_env("filter.evil")); + assert_eq!(commands[6].env, filter_override_env("filter.evil")); + } + + #[tokio::test] + async fn get_git_diff_preserves_builtin_fsmonitor_for_diff_workflow() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![ + response( + git_command( + FsmonitorOverride::Disabled, + &["rev-parse", "--is-inside-work-tree"], + ), + /*exit_code*/ 0, + "true\n", + ), + response( + git_probe_command(&["config", "--null", "--get", "core.fsmonitor"]), + /*exit_code*/ 0, + "true\0", + ), + response( + git_probe_command(&["version", "--build-options"]), + /*exit_code*/ 0, + "feature: fsmonitor--daemon\n", + ), + response( + git_command( + FsmonitorOverride::BuiltIn, + &[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ], + ), + /*exit_code*/ 1, + "", + ), + response( + git_command( + FsmonitorOverride::BuiltIn, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ], + ), + /*exit_code*/ 1, + "tracked\n", + ), + response( + git_command( + FsmonitorOverride::BuiltIn, + &["ls-files", "--others", "--exclude-standard"], + ), + /*exit_code*/ 0, + "new.txt\n", + ), + response( + git_command( + FsmonitorOverride::BuiltIn, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + "--no-index", + "--", + null_device(), + "new.txt", + ], + ), + /*exit_code*/ 1, + "untracked\n", + ), + ]); + + let result = get_git_diff(&runner, &cwd).await; + + assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string()))); + assert_command_metadata(&runner.commands(), &cwd); } #[tokio::test] @@ -348,35 +477,52 @@ mod tests { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - git_command(&["rev-parse", "--is-inside-work-tree"]), + git_command( + FsmonitorOverride::Disabled, + &["rev-parse", "--is-inside-work-tree"], + ), /*exit_code*/ 0, "true\n", ), response( - git_command(&[ - "config", - "--null", - "--name-only", - "--get-regexp", - EXECUTABLE_FILTER_CONFIG_PATTERN, - ]), + git_probe_command(&["config", "--null", "--get", "core.fsmonitor"]), /*exit_code*/ 1, "", ), response( - git_command(&[ - "diff", - "--no-textconv", - "--no-ext-diff", - "--submodule=short", - "--ignore-submodules=dirty", - "--color", - ]), + git_command( + FsmonitorOverride::Disabled, + &[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ], + ), + /*exit_code*/ 1, + "", + ), + response( + git_command( + FsmonitorOverride::Disabled, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ], + ), /*exit_code*/ 1, "tracked\n", ), response( - git_command(&["ls-files", "--others", "--exclude-standard"]), + git_command( + FsmonitorOverride::Disabled, + &["ls-files", "--others", "--exclude-standard"], + ), /*exit_code*/ 0, "", ), @@ -385,6 +531,7 @@ mod tests { let result = get_git_diff(&runner, &cwd).await; assert_eq!(result, Ok((true, "tracked\n".to_string()))); + assert_command_metadata(&runner.commands(), &cwd); } #[tokio::test] @@ -392,35 +539,52 @@ mod tests { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - git_command(&["rev-parse", "--is-inside-work-tree"]), + git_command( + FsmonitorOverride::Disabled, + &["rev-parse", "--is-inside-work-tree"], + ), /*exit_code*/ 0, "true\n", ), response( - git_command(&[ - "config", - "--null", - "--name-only", - "--get-regexp", - EXECUTABLE_FILTER_CONFIG_PATTERN, - ]), + git_probe_command(&["config", "--null", "--get", "core.fsmonitor"]), /*exit_code*/ 1, "", ), response( - git_command(&[ - "diff", - "--no-textconv", - "--no-ext-diff", - "--submodule=short", - "--ignore-submodules=dirty", - "--color", - ]), + git_command( + FsmonitorOverride::Disabled, + &[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ], + ), + /*exit_code*/ 1, + "", + ), + response( + git_command( + FsmonitorOverride::Disabled, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ], + ), /*exit_code*/ 2, "", ), response( - git_command(&["ls-files", "--others", "--exclude-standard"]), + git_command( + FsmonitorOverride::Disabled, + &["ls-files", "--others", "--exclude-standard"], + ), /*exit_code*/ 0, "", ), @@ -430,12 +594,11 @@ mod tests { .await .expect_err("unexpected git diff status should fail"); - assert!( - error.contains( - "git [\"diff\", \"--no-textconv\", \"--no-ext-diff\", \"--submodule=short\", \"--ignore-submodules=dirty\", \"--color\"] failed with status 2" - ), - "unexpected error: {error}", + assert_eq!( + error, + "git [\"diff\", \"--no-textconv\", \"--no-ext-diff\", \"--submodule=short\", \"--ignore-submodules=dirty\", \"--color\"] failed with status 2" ); + assert_command_metadata(&runner.commands(), &cwd); } #[cfg(unix)] @@ -505,11 +668,18 @@ mod tests { .await .expect("generate diff without invoking helpers"); - assert!(result.1.contains("before")); - assert!(result.1.contains("after")); - assert!(!filter_helper.with_extension("sh.ran").exists()); - assert!(!fsmonitor_helper.with_extension("sh.ran").exists()); - assert!(!hook_helper.with_extension("sh.ran").exists()); + assert_eq!( + ( + result.1.contains("before"), + result.1.contains("after"), + filter_helper.with_extension("sh.ran").exists(), + fsmonitor_helper.with_extension("sh.ran").exists(), + hook_helper.with_extension("sh.ran").exists(), + ), + (true, true, false, false, false), + "diff:\n{}", + result.1 + ); } #[cfg(unix)] @@ -565,20 +735,32 @@ mod tests { .await .expect("generate diff without inspecting submodule worktrees"); - assert!(result.1.is_empty()); - assert!(!helper.with_extension("sh.ran").exists()); + assert_eq!( + (result.1, helper.with_extension("sh.ran").exists()), + (String::new(), false) + ); } - fn git_command(args: &[&str]) -> Vec { - let mut argv = vec![ - "git".to_string(), - "-c".to_string(), - DISABLE_FSMONITOR_CONFIG.to_string(), - "-c".to_string(), - DISABLE_HOOKS_CONFIG.to_string(), - ]; - argv.extend(args.iter().map(|arg| (*arg).to_string())); - argv + fn git_command(fsmonitor: FsmonitorOverride, args: &[&str]) -> Vec { + [ + "git", + "-c", + fsmonitor.git_config_arg(), + "-c", + DISABLE_HOOKS_CONFIG, + ] + .into_iter() + .chain(args.iter().copied()) + .map(str::to_string) + .collect() + } + + fn git_probe_command(args: &[&str]) -> Vec { + ["git"] + .into_iter() + .chain(args.iter().copied()) + .map(str::to_string) + .collect() } fn filter_override_env(driver: &str) -> HashMap> { @@ -619,12 +801,18 @@ mod tests { #[cfg(unix)] fn run_git_setup(cwd: &Path, args: &[&str]) { - let status = ProcessCommand::new("git") + let output = ProcessCommand::new("git") .args(args) .current_dir(cwd) - .status() + .output() .expect("run git setup command"); - assert!(status.success(), "git setup command failed: {args:?}"); + assert_eq!( + output.status.code(), + Some(0), + "git setup command failed: {args:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); } #[cfg(unix)] @@ -638,17 +826,21 @@ mod tests { fs::set_permissions(path, permissions).expect("make helper executable"); } - fn assert_commands(commands: &[WorkspaceCommand], expected: &[Vec], cwd: &Path) { - let actual: Vec> = commands - .iter() - .map(|command| command.argv.clone()) - .collect(); - assert_eq!(actual, expected); - + fn assert_command_metadata(commands: &[WorkspaceCommand], cwd: &Path) { for command in commands { assert_eq!(command.cwd.as_deref(), Some(cwd)); - assert_eq!(command.timeout, DIFF_COMMAND_TIMEOUT); - assert!(command.disable_output_cap); + if matches!( + command.argv.get(1).map(String::as_str), + Some("config" | "version") + ) { + assert_eq!(command.env, HashMap::new()); + assert_eq!(command.timeout, Duration::from_secs(/*secs*/ 5)); + assert_eq!(command.output_bytes_cap, 64 * 1024); + assert_eq!(command.disable_output_cap, false); + } else { + assert_eq!(command.timeout, DIFF_COMMAND_TIMEOUT); + assert_eq!(command.disable_output_cap, true); + } } } @@ -671,6 +863,11 @@ mod tests { } fn commands(&self) -> Vec { + assert_eq!( + self.responses.lock().expect("responses lock").len(), + 0, + "unused fake responses" + ); self.commands.lock().expect("commands lock").clone() } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9a23705731..bf89ca22df 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -130,7 +130,6 @@ mod diff_model; mod diff_render; mod exec_cell; mod exec_command; -mod external_agent_config_migration; mod external_editor; mod file_search; mod frames; @@ -152,6 +151,7 @@ mod local_chatgpt_auth; mod markdown; mod markdown_render; mod markdown_stream; +mod markdown_text_merge; mod mention_codec; mod model_catalog; mod model_migration; @@ -190,6 +190,7 @@ mod terminal_title; mod terminal_visualization_instructions; mod text_formatting; mod theme_picker; +mod thread_transcript; mod token_usage; mod tooltips; mod transcript_reflow; @@ -1835,6 +1836,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), + cloud_config_bundle, prompt, images, session_selection, diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 9847c6dc3a..52b603bce0 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -39,6 +39,7 @@ //! body rows, or even 3-char-wide columns cannot fit, body rows render as //! key/value records. +use crate::markdown_text_merge::DecodedTextMerge; use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; @@ -322,7 +323,7 @@ pub(crate) fn render_markdown_lines_with_width_and_cwd( let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); - let parser = Parser::new_ext(input, options).into_offset_iter(); + let parser = DecodedTextMerge::new(Parser::new_ext(input, options).into_offset_iter()); let mut w = Writer::new(input, parser, width, cwd); w.run(); w.text diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 206bae1b70..e0e498e69a 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -8,9 +8,11 @@ use std::path::Path; use crate::markdown_render::COLON_LOCATION_SUFFIX_RE; use crate::markdown_render::HASH_LOCATION_SUFFIX_RE; +use crate::markdown_render::render_markdown_lines_with_width_and_cwd; use crate::markdown_render::render_markdown_text; use crate::markdown_render::render_markdown_text_with_width; use crate::markdown_render::render_markdown_text_with_width_and_cwd; +use insta::assert_debug_snapshot; use insta::assert_snapshot; fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> { @@ -29,6 +31,92 @@ fn plain_lines(text: &Text<'_>) -> Vec { .collect() } +#[test] +fn bare_url_with_tilde_keeps_complete_hyperlink() { + let destination = + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf"; + let lines = render_markdown_lines_with_width_and_cwd( + destination, + /*width*/ Some(80), + /*cwd*/ None, + ); + let rendered = lines + .iter() + .map(|line| { + let text = line + .line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let hyperlinks = line + .hyperlinks + .iter() + .map(|link| (link.columns.clone(), link.destination.as_str())) + .collect::>(); + (text, hyperlinks) + }) + .collect::>(); + + assert_debug_snapshot!(rendered); +} + +#[test] +fn table_url_with_tilde_keeps_complete_hyperlink() { + let destination = + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf"; + let markdown = format!("| URL |\n| --- |\n| {destination} |\n"); + let lines = render_markdown_lines_with_width_and_cwd( + &markdown, + /*width*/ Some(32), + /*cwd*/ None, + ); + let destinations = lines + .iter() + .flat_map(|line| line.hyperlinks.iter()) + .map(|link| link.destination.as_str()) + .collect::>(); + + assert!(!destinations.is_empty()); + assert_eq!(destinations, vec![destination; destinations.len()]); +} + +#[test] +fn merged_text_events_preserve_entity_decoding() { + let source = "https://example.com/a&b~c"; + let destination = "https://example.com/a&b~c"; + let lines = render_markdown_lines_with_width_and_cwd( + source, + /*width*/ Some(80), + /*cwd*/ None, + ); + let rendered = lines + .iter() + .map(|line| { + let text = line + .line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let hyperlinks = line + .hyperlinks + .iter() + .map(|link| (link.columns.clone(), link.destination.as_str())) + .collect::>(); + (text, hyperlinks) + }) + .collect::>(); + + assert_eq!( + rendered, + vec![( + destination.to_string(), + vec![(0..destination.len(), destination)], + )], + ); +} + #[test] fn empty() { assert_eq!(render_markdown_text(""), Text::default()); diff --git a/codex-rs/tui/src/markdown_text_merge.rs b/codex-rs/tui/src/markdown_text_merge.rs new file mode 100644 index 0000000000..bad46fe5df --- /dev/null +++ b/codex-rs/tui/src/markdown_text_merge.rs @@ -0,0 +1,50 @@ +//! Markdown text-event merging that preserves parser-decoded contents and source offsets. + +use std::iter::Peekable; +use std::ops::Range; + +use pulldown_cmark::Event; + +/// Merges adjacent parsed text events without reconstructing them from the Markdown source. +/// +/// Markdown extensions can split visually contiguous text around delimiter characters. Keeping the +/// decoded event contents together lets downstream consumers recognize tokens that span those +/// parser boundaries while the combined source range remains available for offset-aware rendering. +pub(crate) struct DecodedTextMerge { + iter: Peekable, +} + +impl DecodedTextMerge { + pub(crate) fn new(iter: I) -> Self { + Self { + iter: iter.peekable(), + } + } +} + +impl<'a, I> Iterator for DecodedTextMerge +where + I: Iterator, Range)>, +{ + type Item = (Event<'a>, Range); + + fn next(&mut self) -> Option { + let (event, mut range) = self.iter.next()?; + let Event::Text(text) = event else { + return Some((event, range)); + }; + if !matches!(self.iter.peek(), Some((Event::Text(_), _))) { + return Some((Event::Text(text), range)); + } + + let mut merged = text.into_string(); + while matches!(self.iter.peek(), Some((Event::Text(_), _))) { + let Some((Event::Text(text), next_range)) = self.iter.next() else { + break; + }; + merged.push_str(&text); + range.end = next_range.end; + } + Some((Event::Text(merged.into()), range)) + } +} diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 0532ea9fb5..aa47d95c65 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::CollabAgentState; use codex_app_server_protocol::CollabAgentStatus; use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::SubAgentActivityKind; use codex_app_server_protocol::ThreadItem; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -35,10 +36,21 @@ pub(crate) struct AgentPickerThreadEntry { pub(crate) agent_nickname: Option, /// Agent type shown in brackets when present, for example `worker`. pub(crate) agent_role: Option, + /// Canonical v2 agent path, when the thread was observed through v2 activity. + pub(crate) agent_path: Option, + /// Whether the latest liveness refresh says the agent thread is actively working. + pub(crate) is_running: bool, /// Whether the thread has emitted a close event and should render dimmed. pub(crate) is_closed: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SubAgentActivityDisplay { + pub(crate) thread_id: ThreadId, + pub(crate) agent_path: String, + pub(crate) is_running_hint: bool, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct AgentMetadata { /// Human-friendly nickname shown in rendered tool-call rows. @@ -266,6 +278,56 @@ pub(crate) fn tool_call_history_cell( } } +pub(crate) fn sub_agent_activity_display(item: &ThreadItem) -> Option { + let ThreadItem::SubAgentActivity { + kind, + agent_thread_id, + agent_path, + .. + } = item + else { + return None; + }; + Some(SubAgentActivityDisplay { + thread_id: parse_thread_id(agent_thread_id)?, + agent_path: agent_path.clone(), + is_running_hint: !matches!(kind, SubAgentActivityKind::Interrupted), + }) +} + +pub(crate) fn sub_agent_activity_history_cell(item: &ThreadItem) -> Option { + let ThreadItem::SubAgentActivity { + kind, agent_path, .. + } = item + else { + return None; + }; + Some(collab_event( + sub_agent_activity_title(*kind, agent_path), + Vec::new(), + )) +} + +pub(crate) fn sub_agent_activity_summary(kind: SubAgentActivityKind, agent_path: &str) -> String { + match kind { + SubAgentActivityKind::Started => format!("Started `{agent_path}`"), + SubAgentActivityKind::Interacted => format!("Interacted with `{agent_path}`"), + SubAgentActivityKind::Interrupted => format!("Interrupted `{agent_path}`"), + } +} + +fn sub_agent_activity_title(kind: SubAgentActivityKind, agent_path: &str) -> Line<'static> { + let (prefix, path) = match kind { + SubAgentActivityKind::Started => ("Started ", agent_path), + SubAgentActivityKind::Interacted => ("Interacted with ", agent_path), + SubAgentActivityKind::Interrupted => ("Interrupted ", agent_path), + }; + title_spans_line(vec![ + Span::from(prefix).bold(), + Span::from(format!("`{path}`")).cyan(), + ]) +} + fn spawn_end( new_thread_id: Option, prompt: &str, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index de9a46e8ff..c736fe9b6e 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -4,8 +4,6 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; -mod transcript; - use crate::app_server_session::AppServerSession; use crate::clipboard_paste::normalize_pasted_search_query; use crate::color::blend; @@ -25,6 +23,9 @@ use crate::status::format_directory_display; use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; use crate::text_formatting::truncate_text; +use crate::thread_transcript::RawReasoningVisibility; +use crate::thread_transcript::TranscriptCells; +use crate::thread_transcript::load_session_transcript; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; @@ -60,9 +61,6 @@ use tokio::sync::mpsc; use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::warn; -use transcript::RawReasoningVisibility; -use transcript::TranscriptCells; -use transcript::load_session_transcript; use unicode_width::UnicodeWidthStr; const PAGE_SIZE: usize = 25; @@ -5751,7 +5749,7 @@ session_picker_view = "dense" #[test] fn thread_to_transcript_cells_renders_core_message_types() { - use transcript::thread_to_transcript_cells; + use crate::thread_transcript::thread_to_transcript_cells; let thread_id = ThreadId::new(); let thread = Thread { @@ -5820,7 +5818,7 @@ session_picker_view = "dense" #[test] fn thread_to_transcript_cells_hides_raw_reasoning_when_not_enabled() { - use transcript::thread_to_transcript_cells; + use crate::thread_transcript::thread_to_transcript_cells; let thread_id = ThreadId::new(); let thread = Thread { @@ -5878,7 +5876,7 @@ session_picker_view = "dense" #[test] fn thread_to_transcript_cells_shows_raw_reasoning_over_summary_when_enabled() { - use transcript::thread_to_transcript_cells; + use crate::thread_transcript::thread_to_transcript_cells; let thread_id = ThreadId::new(); let thread = Thread { diff --git a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap new file mode 100644 index 0000000000..b4ccc63a9a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/debug_config.rs +expression: rendered.as_str() +--- +/debug-config + +Config layer stack (lowest precedence first): + + +Requirements: + - allowed_sandbox_modes: read-only, workspace-write (source: /etc/codex/requirements.toml) + - permissions.filesystem.deny_read: /home/alice/.gitconfig (source: /etc/codex/requirements.toml) diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap deleted file mode 100644 index 5ce3c5ef09..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -source: tui/src/external_agent_config_migration.rs -expression: rendered ---- - > External agent config detected - We found settings from another agent that you can add to this project. - Select what to import - Home - [x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con… - - Project: /workspace/project - [x] Migrate enabled plugins from .claude/settings.json (4 marketplaces, 6… - • acme-tools: deployer, formatter, +1 more - • team-marketplace: asana - • debug: sample - • +1 more marketplaces - [x] Migrate CLAUDE.md to AGENTS.md - - Selected 3 of 3 item(s). - 1. Proceed with selected - 2. Skip for now - 3. Don't ask again - Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap deleted file mode 100644 index 44f41784e6..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -source: tui/src/external_agent_config_migration.rs -expression: rendered ---- - - > External agent config detected - We found settings from another agent that you can add to this project. - Select what to import - Home - [x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con… - - Project: C:\workspace\project - [x] Migrate enabled plugins from .claude/settings.json (4 marketplaces, 6… - • acme-tools: deployer, formatter, +1 more - • team-marketplace: asana - • debug: sample - • +1 more marketplaces - [x] Migrate CLAUDE.md to AGENTS.md - - Selected 3 of 3 item(s). - 1. Proceed with selected - 2. Skip for now - 3. Don't ask again - Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap new file mode 100644 index 0000000000..c10dccd9ca --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: rendered +--- +[ + ( + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf", + [ + ( + 0..72, + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf", + ), + ], + ), +] diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/thread_transcript.rs similarity index 96% rename from codex-rs/tui/src/resume_picker/transcript.rs rename to codex-rs/tui/src/thread_transcript.rs index e211a71265..b530dfacf4 100644 --- a/codex-rs/tui/src/resume_picker/transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -1,3 +1,5 @@ +//! Render persisted thread turns into history-cell building blocks. + use std::sync::Arc; use crate::app_server_session::AppServerSession; @@ -7,6 +9,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::ReasoningSummaryCell; use crate::history_cell::UserHistoryCell; +use crate::multi_agents::sub_agent_activity_summary; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; use codex_protocol::ThreadId; @@ -191,6 +194,11 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option { ThreadItem::CollabAgentToolCall { tool, status, .. } => { vec![format!("agent tool: {tool:?} · {status:?}").dim().into()] } + ThreadItem::SubAgentActivity { + kind, agent_path, .. + } => { + vec![sub_agent_activity_summary(*kind, agent_path).dim().into()] + } ThreadItem::WebSearch { query, .. } => { vec![vec!["web search: ".dim(), query.clone().into()].into()] } diff --git a/codex-rs/utils/path-uri/BUILD.bazel b/codex-rs/utils/path-uri/BUILD.bazel new file mode 100644 index 0000000000..7bfd2c4542 --- /dev/null +++ b/codex-rs/utils/path-uri/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "path-uri", + crate_name = "codex_utils_path_uri", +) diff --git a/codex-rs/utils/path-uri/Cargo.toml b/codex-rs/utils/path-uri/Cargo.toml new file mode 100644 index 0000000000..1cf70708e7 --- /dev/null +++ b/codex-rs/utils/path-uri/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codex-utils-path-uri" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +ts-rs = { workspace = true, features = ["no-serde-warnings"] } +url = { workspace = true } +urlencoding = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } + +[lib] +doctest = false diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs new file mode 100644 index 0000000000..a788fdc9b4 --- /dev/null +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -0,0 +1,327 @@ +//! Typed, immutable `file:` URIs with cross-platform path inspection. +//! +//! See [`PathUri`] for scheme, normalization, and serialization behavior. + +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; +use std::fmt; +use std::str::FromStr; +use thiserror::Error; +use ts_rs::TS; +use url::Url; + +pub const FILE_SCHEME: &str = "file"; + +/// An immutable, cross-platform representation of a `file:` URI. +/// +/// Only the `file:` scheme is currently accepted. Construction validates the +/// URL, and the URI cannot be mutated after construction. [`Self::basename`], +/// [`Self::parent`], and [`Self::join`] operate on URI path segments without +/// interpreting them using the operating system running Codex. +/// +/// `file:` paths retain their URI spelling so they can be parsed independently +/// of the current host. In particular, `/C:/src` remains ambiguous between a +/// Windows drive path and a valid POSIX path until [`Self::to_native_path`] +/// applies the current host's rules. A local POSIX `file:` URI can also retain +/// percent-encoded non-UTF-8 bytes for lossless native round trips. +/// +/// Like [VS Code resources], path operations use `/` URI separators on every +/// host. They preserve a URL authority but do not infer Windows drive or UNC +/// roots from path text. Native path normalization, filesystem aliases, +/// symlinks, case sensitivity, and Unicode normalization are not resolved. +/// +/// Serde represents a `PathUri` as its canonical URI string. Deserialization +/// also accepts an absolute native path for compatibility with fields that +/// previously used [`AbsolutePathBuf`]; relative paths are rejected. Valid +/// `file:` strings round-trip through their canonical URL form, including +/// encoded non-UTF-8 path bytes, but conversion to a native path remains +/// host-dependent as described by [RFC 8089]. +/// +/// [RFC 8089]: https://www.rfc-editor.org/rfc/rfc8089.html +/// [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts +#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)] +#[ts(type = "string")] +pub struct PathUri(Url); + +impl PathUri { + /// Parses and validates a `file:` URI. + pub fn parse(uri: &str) -> Result { + Url::parse(uri)?.try_into() + } + + /// Converts an absolute path on the current host to a `file:` URI. + /// + /// On Unix, [`AbsolutePathBuf`]'s absolute-path invariant is sufficient for + /// `url` to represent the path. On Windows, conversion can still fail for + /// absolute paths whose prefix has no `file:` URI representation, including + /// `\\.\` device paths and generic `\\?\` verbatim namespaces. Those cases + /// return [`PathUriParseError::InvalidFileUriPath`]. + pub fn from_file_path(path: &AbsolutePathBuf) -> Result { + let url = Url::from_file_path(path.as_path()) + .map_err(|()| PathUriParseError::InvalidFileUriPath)?; + Self::try_from(url) + } + + /// Returns the percent-encoded URI path. + /// + /// The URL authority is not included. For example, + /// `file://server/share/file.rs` has the path `/share/file.rs`. + pub fn encoded_path(&self) -> &str { + self.0.path() + } + + /// Returns the decoded final URI path segment, or `None` for the URI root. + /// + /// If the segment contains non-UTF-8 encoded bytes, its percent-encoded + /// spelling is returned instead. + pub fn basename(&self) -> Option { + self.0 + .path_segments()? + .rfind(|segment| !segment.is_empty()) + .map(decode_uri_path) + } + + /// Returns the parent URI, or `None` for the URI root. + pub fn parent(&self) -> Option { + if self.encoded_path() == "/" { + return None; + } + + let mut url = self.0.clone(); + { + let mut segments = match url.path_segments_mut() { + Ok(segments) => segments, + Err(()) => unreachable!("validated file URLs support hierarchical path segments"), + }; + segments.pop_if_empty().pop(); + } + Some(Self(url)) + } + + /// Lexically joins a relative URI path onto this URI. + /// + /// Empty and `.` segments are ignored, while `..` removes one segment + /// without escaping the URI root. Literal `%`, `?`, and `#` characters are + /// percent-encoded as filename text. Paths containing a null character are + /// rejected because they cannot be safely converted to native paths. + pub fn join(&self, path: &str) -> Result { + if path.starts_with('/') { + return Err(PathUriParseError::JoinPathMustBeRelative(path.to_string())); + } + if path.contains('\0') { + return Err(PathUriParseError::InvalidFileUriPath); + } + if path.is_empty() { + return Ok(self.clone()); + } + + let mut url = self.0.clone(); + { + let Ok(mut segments) = url.path_segments_mut() else { + unreachable!("validated file URLs support hierarchical path segments"); + }; + segments.pop_if_empty(); + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + segments.pop(); + } + component => { + segments.push(component); + } + } + } + } + Self::try_from(url) + } + + /// Converts this file URI to a path using the current host's path rules. + /// + /// Conversion should succeed when the URI was created from an + /// [`AbsolutePathBuf`] on the current host. It may fail when the URI came + /// from a different operating system and its `file:` URI form cannot be + /// represented using the current host's path rules, such as a UNC authority + /// on POSIX or a POSIX root on Windows. Because a `file:` URI does not record + /// its source operating system, callers should only use this method when the + /// URI is known to identify a path on the current host. + pub fn to_native_path(&self) -> Result { + let path = self + .0 + .to_file_path() + .map_err(|()| PathUriParseError::InvalidFileUriPath)?; + AbsolutePathBuf::from_absolute_path_checked(path) + .map_err(|_| PathUriParseError::InvalidFileUriPath) + } + + /// Returns a clone of the canonical URL. + pub fn to_url(&self) -> Url { + self.0.clone() + } +} + +impl TryFrom for PathUri { + type Error = PathUriParseError; + + fn try_from(url: Url) -> Result { + if url.scheme() != FILE_SCHEME { + return Err(PathUriParseError::UnsupportedScheme( + url.scheme().to_string(), + )); + } + validate_file_url(&url)?; + let url = without_localhost_authority(url); + Ok(Self(url)) + } +} + +impl TryFrom for PathUri { + type Error = PathUriParseError; + + fn try_from(uri: String) -> Result { + Self::parse(&uri) + } +} + +impl<'de> Deserialize<'de> for PathUri { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let unsupported_scheme = match Url::parse(&value) { + Ok(url) => match Self::try_from(url) { + Ok(uri) => return Ok(uri), + // `Url` parses a Windows drive prefix such as `C:\` as the + // scheme `c`. Give any unsupported URI one chance to satisfy + // the native absolute-path invariant before reporting it. + Err(error @ PathUriParseError::UnsupportedScheme(_)) => Some(error), + Err(error) => return Err(serde::de::Error::custom(error)), + }, + Err(url::ParseError::RelativeUrlWithoutBase) => None, + Err(error) => { + return Err(serde::de::Error::custom(PathUriParseError::InvalidUri( + error, + ))); + } + }; + + let path = AbsolutePathBuf::from_absolute_path_checked(value).map_err(|path_error| { + serde::de::Error::custom( + unsupported_scheme + .map_or_else(|| path_error.to_string(), |error| error.to_string()), + ) + })?; + Self::from_file_path(&path).map_err(serde::de::Error::custom) + } +} + +impl FromStr for PathUri { + type Err = PathUriParseError; + + fn from_str(uri: &str) -> Result { + Self::parse(uri) + } +} + +impl fmt::Display for PathUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl Serialize for PathUri { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.0.as_str()) + } +} + +impl JsonSchema for PathUri { + fn schema_name() -> String { + "PathUri".to_string() + } + + fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(generator) + } +} + +/// Removes the local `localhost` alias while retaining non-local UNC authority. +fn without_localhost_authority(mut url: Url) -> Url { + if url.host_str() == Some("localhost") { + let Ok(()) = url.set_host(None) else { + unreachable!("validated file URLs can remove a localhost authority"); + }; + } + url +} + +/// Percent-decodes a URI path when it is valid UTF-8. +/// +/// `file:` URLs may contain encoded non-UTF-8 bytes. In that case the encoded +/// spelling remains available for lexical inspection while the original `Url` +/// is retained for lossless native conversion. +fn decode_uri_path(path: &str) -> String { + urlencoding::decode(path) + .map(std::borrow::Cow::into_owned) + .unwrap_or_else(|_| path.to_string()) +} + +/// Rejects URI metadata that has no defined meaning for `file:` URIs. +fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> { + if !url.username().is_empty() || url.password().is_some() { + return Err(PathUriParseError::CredentialsNotAllowed); + } + if url.port().is_some() { + return Err(PathUriParseError::PortNotAllowed); + } + if url.query().is_some() { + return Err(PathUriParseError::QueryNotAllowed); + } + if url.fragment().is_some() { + return Err(PathUriParseError::FragmentNotAllowed); + } + Ok(()) +} + +/// Applies the common URI checks plus `file:` path-byte restrictions. +fn validate_file_url(url: &Url) -> Result<(), PathUriParseError> { + validate_common_known_uri(url)?; + // `Url` accepts `%00`, but native path APIs use null as a terminator and + // `Url::to_file_path` cannot represent a decoded null byte. + if urlencoding::decode_binary(url.path().as_bytes()).contains(&0) { + return Err(PathUriParseError::InvalidFileUriPath); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PathUriParseError { + #[error("invalid URI: {0}")] + InvalidUri(#[from] url::ParseError), + #[error("unsupported path URI scheme `{0}`")] + UnsupportedScheme(String), + #[error("file URI contains an invalid absolute path")] + InvalidFileUriPath, + #[error("credentials are not allowed in path URIs")] + CredentialsNotAllowed, + #[error("ports are not allowed in path URIs")] + PortNotAllowed, + #[error("query parameters are not allowed in path URIs")] + QueryNotAllowed, + #[error("fragments are not allowed in path URIs")] + FragmentNotAllowed, + #[error("path `{0}` must be relative when joining a path URI")] + JoinPathMustBeRelative(String), +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs new file mode 100644 index 0000000000..6a237a8c1e --- /dev/null +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use pretty_assertions::assert_eq; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; +#[cfg(unix)] +use std::path::PathBuf; + +#[test] +fn file_uri_round_trips_an_absolute_path() { + let path = AbsolutePathBuf::current_dir() + .expect("current directory") + .join("a path/file.rs"); + + let uri = PathUri::from_file_path(&path).expect("path should convert to a file URI"); + + let uri_string = uri.to_string(); + assert!(uri_string.starts_with("file:")); + assert!(uri_string.ends_with("/a%20path/file.rs")); + assert_eq!( + PathUri::parse(&uri_string).expect("serialized URI should parse"), + uri + ); + assert_eq!( + uri.to_native_path() + .expect("local file URI should convert to a native path"), + path + ); +} + +#[test] +fn file_uri_parses_a_windows_path_on_any_host() { + let uri = PathUri::parse("file:///C:/Users/Alice%20Smith/src/main.rs") + .expect("Windows file URI should parse on every host"); + + assert_eq!(uri.encoded_path(), "/C:/Users/Alice%20Smith/src/main.rs"); + assert_eq!(uri.basename(), Some("main.rs".to_string())); + assert_eq!( + uri.to_string(), + "file:///C:/Users/Alice%20Smith/src/main.rs" + ); +} + +#[cfg(windows)] +#[test] +fn file_uri_rejects_windows_prefixes_without_a_uri_representation() { + for native_path in [ + r"\\.\COM1", + r"\\?\Volume{00000000-0000-0000-0000-000000000000}\file.rs", + ] { + let path = AbsolutePathBuf::from_absolute_path_checked(native_path) + .expect("Windows namespace path should be absolute"); + + assert_eq!( + PathUri::from_file_path(&path), + Err(PathUriParseError::InvalidFileUriPath), + "converting {native_path}" + ); + } +} + +#[test] +fn file_uri_parses_a_posix_path_on_any_host() { + let uri = PathUri::parse("file:///home/alice/src/main.rs") + .expect("POSIX file URI should parse on every host"); + + assert_eq!(uri.encoded_path(), "/home/alice/src/main.rs"); + assert_eq!(uri.basename(), Some("main.rs".to_string())); + assert_eq!(uri.to_string(), "file:///home/alice/src/main.rs"); +} + +#[test] +fn file_uri_preserves_paths_that_resemble_windows_paths() { + for (input, expected_path) in [("file:///C:/Project", "/C:/Project"), ("file:///C:", "/C:")] { + let uri = PathUri::parse(input).expect("file URI should parse"); + let reparsed = PathUri::parse(&uri.to_string()).expect("file URI should reparse"); + assert_eq!(uri.encoded_path(), expected_path); + assert_eq!(reparsed, uri); + } +} + +#[test] +#[cfg(unix)] +fn file_uri_accepts_non_utf8_posix_paths() { + let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/non-utf8-\xff".to_vec())); + let path = AbsolutePathBuf::from_absolute_path_checked(path).expect("absolute POSIX path"); + + let uri = PathUri::from_file_path(&path).expect("non-UTF-8 path should convert to a file URI"); + assert_eq!( + uri.to_native_path() + .expect("URI should convert to native path"), + path + ); + assert_eq!( + PathUri::parse(&uri.to_string()).expect("non-UTF-8 URI should reparse"), + uri + ); +} + +#[test] +fn file_uri_round_trips_literal_percent_characters() { + let uri = PathUri::parse("file:///tmp/100%25/file").expect("file URI should parse"); + + assert_eq!(uri.to_string(), "file:///tmp/100%25/file"); + assert_eq!(uri.encoded_path(), "/tmp/100%25/file"); + assert_eq!(uri.basename(), Some("file".to_string())); +} + +#[test] +#[cfg(windows)] +fn file_uri_round_trips_windows_unc_paths() { + let path = AbsolutePathBuf::from_absolute_path_checked(r"\\server\share\src\main.rs") + .expect("absolute UNC path"); + let uri = PathUri::from_file_path(&path).expect("UNC path should convert to a file URI"); + + assert_eq!(uri.encoded_path(), "/share/src/main.rs"); + assert_eq!(uri.to_native_path().expect("UNC URI should convert"), path); +} + +#[test] +fn file_uri_retains_unc_authority() { + let uri = PathUri::parse("file://server/share/src/main.rs").expect("valid file URI"); + + assert_eq!(uri.encoded_path(), "/share/src/main.rs"); + assert_eq!(uri.to_string(), "file://server/share/src/main.rs"); +} + +#[test] +fn file_uri_spelling_aliases_have_one_canonical_form() { + for input in [ + "FILE:///workspace/src", + "file:/workspace/src", + "file://localhost/workspace/src", + "file://LOCALHOST/workspace/src", + ] { + let uri = PathUri::parse(input).expect("file URI alias should parse"); + assert_eq!(uri.to_string(), "file:///workspace/src", "parsing {input}"); + } +} + +#[test] +fn unsupported_schemes_are_rejected_at_construction() { + for (input, expected_scheme) in [ + ("codex-env:///devbox/workspace", "codex-env"), + ("artifact://store/object-1", "artifact"), + ("http://example.com/file", "http"), + ("https://example.com/file", "https"), + ("ssh://host/workspace", "ssh"), + ("vscode-remote://ssh-remote+host/workspace", "vscode-remote"), + ("untitled:Untitled-1", "untitled"), + ] { + let error = PathUri::parse(input).expect_err("unsupported schemes should be rejected"); + + assert!( + matches!( + error, + PathUriParseError::UnsupportedScheme(scheme) if scheme == expected_scheme + ), + "parsing {input}" + ); + } +} + +#[test] +fn path_uri_serializes_as_a_string() { + let uri: PathUri = "file:///workspace/src/lib.rs" + .parse() + .expect("valid file URI"); + + let json = serde_json::to_string(&uri).expect("URI should serialize"); + let deserialized: PathUri = serde_json::from_str(&json).expect("URI should deserialize"); + + assert_eq!(json, r#""file:///workspace/src/lib.rs""#); + assert_eq!(deserialized, uri); +} + +#[test] +fn path_uri_deserializes_legacy_absolute_paths() { + let path = AbsolutePathBuf::current_dir() + .expect("current directory") + .join("workspace/src"); + let json = serde_json::to_string(&path).expect("absolute path should serialize"); + let uri: PathUri = serde_json::from_str(&json).expect("legacy absolute path should parse"); + + assert_eq!( + uri, + PathUri::from_file_path(&path).expect("expected file URI") + ); +} + +#[test] +fn path_uri_rejects_legacy_relative_paths_with_absolute_path_guard() { + let base = AbsolutePathBuf::current_dir().expect("current directory"); + let _guard = AbsolutePathBufGuard::new(base.as_path()); + let error = serde_json::from_str::(r#""src/lib.rs""#) + .expect_err("legacy relative path should be rejected"); + + assert!(error.to_string().contains("path is not absolute")); +} + +#[test] +fn unsupported_scheme_is_rejected_during_deserialization() { + let error = serde_json::from_str::(r#""artifact://store/object-1""#) + .expect_err("unsupported scheme should fail deserialization"); + + assert!( + error + .to_string() + .contains("unsupported path URI scheme `artifact`") + ); +} + +#[test] +fn known_path_uris_reject_queries_and_fragments() { + let query_error = + PathUri::parse("file:///tmp/file.rs?version=1").expect_err("query should be rejected"); + let fragment_error = + PathUri::parse("file:///tmp/file.rs#L1").expect_err("fragment should be rejected"); + + assert!(matches!(query_error, PathUriParseError::QueryNotAllowed)); + assert!(matches!( + fragment_error, + PathUriParseError::FragmentNotAllowed + )); +} + +#[test] +fn path_uris_reject_encoded_null_bytes() { + assert!(PathUri::parse("file:///tmp/%00").is_err()); +} + +#[test] +fn encoded_filename_characters_round_trip_without_becoming_uri_metadata() { + let uri = PathUri::parse("file:///tmp/a%3Fb%23c%25d") + .expect("encoded filename characters should parse"); + + assert_eq!(uri.to_string(), "file:///tmp/a%3Fb%23c%25d"); + assert_eq!(uri.encoded_path(), "/tmp/a%3Fb%23c%25d"); + assert_eq!(uri.basename(), Some("a?b#c%d".to_string())); +} + +#[test] +fn double_encoded_separator_remains_filename_text() { + let uri = PathUri::parse("file:///tmp/a%252Fb") + .expect("double-encoded separator should parse as filename text"); + + assert_eq!(uri.to_string(), "file:///tmp/a%252Fb"); + assert_eq!(uri.encoded_path(), "/tmp/a%252Fb"); + assert_eq!(uri.basename(), Some("a%2Fb".to_string())); +} + +#[test] +fn basename_uses_decoded_uri_segments() { + for (input, expected) in [ + ("file:///", None), + ("file:///workspace/src/lib.rs", Some("lib.rs")), + ("file:///workspace/a%20file.rs", Some("a file.rs")), + ("file:///C:/", Some("C:")), + ("file://server/share", Some("share")), + ] { + let uri = PathUri::parse(input).expect("valid file URI"); + assert_eq!( + uri.basename(), + expected.map(str::to_string), + "basename for {input}" + ); + } +} + +#[test] +fn parent_uses_uri_hierarchy_and_preserves_authority() { + for (input, expected) in [ + ( + "file:///workspace/src/lib.rs", + Some("file:///workspace/src"), + ), + ("file:///workspace", Some("file:///")), + ("file:///", None), + ("file:///C:/Users", Some("file:///C:")), + ("file:///C:/", Some("file:///")), + ( + "file://server/share/src/main.rs", + Some("file://server/share/src"), + ), + ("file://server/share", Some("file://server/")), + ] { + let uri = PathUri::parse(input).expect("valid file URI"); + let expected = expected.map(|value| PathUri::parse(value).expect("valid expected URI")); + assert_eq!(uri.parent(), expected, "parent for {input}"); + } +} + +#[test] +fn join_normalizes_relative_uri_segments() { + for (base, relative, expected) in [ + ( + "file:///workspace/src", + "../tests/test.rs", + "file:///workspace/tests/test.rs", + ), + ("file:///", "../../etc", "file:///etc"), + ("file:///C:/Users", "../Windows", "file:///C:/Windows"), + ( + "file://server/share/src", + "../tests", + "file://server/share/tests", + ), + ( + "file:///workspace", + "a?b#c%d", + "file:///workspace/a%3Fb%23c%25d", + ), + ("file:///workspace/", "", "file:///workspace/"), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(relative), Ok(expected), "joining {relative}"); + } +} + +#[test] +fn join_rejects_absolute_and_null_paths() { + let base = PathUri::parse("file:///workspace").expect("valid base URI"); + + assert!(matches!( + base.join("/src"), + Err(PathUriParseError::JoinPathMustBeRelative(path)) if path == "/src" + )); + assert!(matches!( + base.join("src\0file"), + Err(PathUriParseError::InvalidFileUriPath) + )); +} + +#[test] +fn to_url_returns_the_validated_url() { + let uri = PathUri::parse("file://localhost/workspace/a%20file.rs").expect("valid file URI"); + + assert_eq!( + uri.to_url(), + Url::parse("file:///workspace/a%20file.rs").expect("valid URL") + ); +}